From 9a24e3157a3351143b717dd67440a0fd8667ce1e Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:04:05 -0500 Subject: [PATCH 01/32] Rebuild the lightbox floater as a declarative tabs-of-accordions shell Replace the stalled WIP floater (dead tonemapper/sharpen machinery wired to removed settings, fixed-pixel 400x380 XUI) with a resizable Look/Lens/Scene shell where each tab is an accordion of effect sections loaded from its own panel XML. Rows bind straight to settings via control_name; the only C++ glue is a generic Vec3/Color3 component binder driven by the "vec3__" widget naming contract and a data-driven per-section reset that walks a section for its bound controls instead of hardcoding key lists. The Look tab ships the proof-of-pattern sections: exposure/tonemapper (with per-operator params and auto-exposure under Advanced), color LUT, split toning (color_swatch on a Color3 setting), and lift/gamma/gain (Vec3 spinner rows). Lens and Scene are placeholders for the next phases. Declare RenderDynamicExposure{Enabled,SpeedError,SpeedTarget}, which the pipeline already reads but settings.xml never declared, and make them and the existing auto-exposure knobs persistent now that they are user-facing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/alfloaterlightbox.cpp | 677 +---- indra/newview/alfloaterlightbox.h | 23 +- indra/newview/app_settings/settings.xml | 37 +- .../xui/en/floater_lightbox_settings.xml | 2637 +---------------- .../default/xui/en/panel_lightbox_lens.xml | 19 + .../default/xui/en/panel_lightbox_look.xml | 1110 +++++++ .../default/xui/en/panel_lightbox_scene.xml | 19 + 7 files changed, 1397 insertions(+), 3125 deletions(-) create mode 100644 indra/newview/skins/default/xui/en/panel_lightbox_lens.xml create mode 100644 indra/newview/skins/default/xui/en/panel_lightbox_look.xml create mode 100644 indra/newview/skins/default/xui/en/panel_lightbox_scene.xml diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index bc057a03c2..2e1f3ffb64 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -1,6 +1,6 @@ /** * @file alfloaterlightbox.cpp - * @brief A generic text floater for dumping info (usually debug info) + * @brief Lightbox post-processing control floater * * Copyright (C) Rye Mutt * @@ -31,43 +31,117 @@ #include "llviewerprecompiledheaders.h" #include "alfloaterlightbox.h" -//#include "alrenderutils.h" -#include "llviewercontrol.h" -#include "llspinctrl.h" -#include "llsliderctrl.h" -#include "lltextbox.h" #include "llcombobox.h" +#include "llpanel.h" +#include "llspinctrl.h" +#include "llviewercontrol.h" + +#include + +namespace +{ +// Vector-valued rows follow the widget naming contract "vec3__<0|1|2>"; +// setting names never contain '_', so the parse is unambiguous. +bool parseVec3WidgetName(const std::string& name, std::string& setting, S32& component) +{ + static const std::string prefix = "vec3_"; + if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) + { + return false; + } + size_t sep = name.rfind('_'); + if (sep <= prefix.size() || sep + 2 != name.size()) + { + return false; + } + S32 comp = name[sep + 1] - '0'; + if (comp < 0 || comp > 2) + { + return false; + } + setting = name.substr(prefix.size(), sep - prefix.size()); + component = comp; + return true; +} + +void collectVec3Spinners(LLView* viewp, std::map>& rows) +{ + for (LLView* childp : *viewp->getChildList()) + { + if (LLSpinCtrl* spinnerp = dynamic_cast(childp)) + { + std::string setting; + S32 component = 0; + if (parseVec3WidgetName(spinnerp->getName(), setting, component)) + { + rows[setting][component] = spinnerp; + } + } + collectVec3Spinners(childp, rows); + } +} + +void collectBoundControls(LLView* viewp, std::set& keys) +{ + for (LLView* childp : *viewp->getChildList()) + { + if (LLUICtrl* ctrlp = dynamic_cast(childp)) + { + if (LLControlVariable* controlp = ctrlp->getControlVariable()) + { + // Only reset controls owned by gSavedSettings; enabled/visibility + // bindings live in separate slots and are not touched here. + if (gSavedSettings.getControl(controlp->getName()) == controlp) + { + keys.insert(controlp->getName()); + } + } + std::string setting; + S32 component = 0; + if (parseVec3WidgetName(ctrlp->getName(), setting, component)) + { + keys.insert(setting); + } + } + collectBoundControls(childp, keys); + } +} +} // namespace ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) : LLFloater(key) { mCommitCallbackRegistrar.add("LightBox.ResetControlDefault", std::bind(&ALFloaterLightBox::onClickResetControlDefault, this, std::placeholders::_2)); - mCommitCallbackRegistrar.add("LightBox.ResetGroupDefault", std::bind(&ALFloaterLightBox::onClickResetGroupDefault, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ResetSection", std::bind(&ALFloaterLightBox::onClickResetSection, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.CommitVec3", std::bind(&ALFloaterLightBox::onCommitVec3, this, std::placeholders::_1)); } ALFloaterLightBox::~ALFloaterLightBox() { - mTonemapConnection.disconnect(); - mCASConnection.disconnect(); } bool ALFloaterLightBox::postBuild() { populateLUTCombo(); - updateTonemapper(); - updateCAS(); - mTonemapConnection = gSavedSettings.getControl("AlchemyRenderTonemapType")->getSignal()->connect([&](LLControlVariable* control, const LLSD&, const LLSD&) { updateTonemapper(); }); - //mCASConnection = gSavedSettings.getControl("RenderSharpenMethod")->getSignal()->connect([&](LLControlVariable* control, const LLSD&, const LLSD&) { updateCAS(); }); + collectVec3Spinners(this, mVec3Rows); + for (const auto& row : mVec3Rows) + { + const std::string& setting = row.first; + LLControlVariable* controlp = gSavedSettings.getControl(setting); + if (!controlp) + { + LL_WARNS() << "Vec3 row bound to unknown setting: " << setting << LL_ENDL; + continue; + } + mVec3Connections.emplace_back(controlp->getSignal()->connect( + [this, setting](LLControlVariable*, const LLSD&, const LLSD&) { refreshVec3Row(setting); })); + refreshVec3Row(setting); + } return LLFloater::postBuild(); } -void ALFloaterLightBox::draw() -{ - LLFloater::draw(); -} - void ALFloaterLightBox::populateLUTCombo() { LLComboBox* lut_combo = getChild("colorlut_combo"); @@ -122,537 +196,82 @@ void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) } } -void ALFloaterLightBox::onClickResetGroupDefault(const LLSD& userdata) +void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) { - const std::string& setting_group = userdata.asString(); - if (setting_group == "sharpen") + const std::string& section = userdata.asString(); + if (section.empty()) { - LLControlVariable* controlp = gSavedSettings.getControl("RenderSharpenMethod"); - if (controlp) - { - controlp->resetToDefault(true); - } - controlp = gSavedSettings.getControl("RenderSharpenCASSharpness"); - if (controlp) - { - controlp->resetToDefault(true); - } - controlp = gSavedSettings.getControl("RenderSharpenDLSSharpness"); - if (controlp) - { - controlp->resetToDefault(true); - } - controlp = gSavedSettings.getControl("RenderSharpenDLSDenoise"); - if (controlp) - { - controlp->resetToDefault(true); - } + return; } - else if (setting_group == "tonemap") + + std::set keys; + if (LLPanel* panelp = findChild(section)) { + collectBoundControls(panelp, keys); + } + if (LLPanel* advp = findChild(section + "_adv")) + { + collectBoundControls(advp, keys); + } + + for (const std::string& key : keys) + { + if (LLControlVariable* controlp = gSavedSettings.getControl(key)) { - LLControlVariable* controlp = gSavedSettings.getControl("RenderExposure"); - if (controlp) - { - controlp->resetToDefault(true); - } + controlp->resetToDefault(true); } - - //S32 tone_map_type = gSavedSettings.getS32("AlchemyRenderTonemapType"); - //switch (tone_map_type) - //{ - //case ALRenderUtil::TONEMAP_AMD: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapAMDHDRMax"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDExposure"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDContrast"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationR"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationG"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationB"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //case ALRenderUtil::TONEMAP_UCHIMURA: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraMaxBrightness"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraContrast"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraLinearStart"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraLinearLength"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraBlackLevel"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //case ALRenderUtil::TONEMAP_UNCHARTED: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapFilmicToeStr"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicToeLen"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderStr"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderLen"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderAngle"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicGamma"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicWhitePoint"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //} } } -void ALFloaterLightBox::updateTonemapper() +void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) { - //Init Text - LLTextBox* text1 = getChild("tonemapper_dynamic_text1"); - LLTextBox* text2 = getChild("tonemapper_dynamic_text2"); - LLTextBox* text3 = getChild("tonemapper_dynamic_text3"); - LLTextBox* text4 = getChild("tonemapper_dynamic_text4"); - LLTextBox* text5 = getChild("tonemapper_dynamic_text5"); - LLTextBox* text6 = getChild("tonemapper_dynamic_text6"); - LLTextBox* text7 = getChild("tonemapper_dynamic_text7"); - - //Init Spinners - LLSpinCtrl* spinner1 = getChild("tonemapper_dynamic_spinner1"); - LLSpinCtrl* spinner2 = getChild("tonemapper_dynamic_spinner2"); - LLSpinCtrl* spinner3 = getChild("tonemapper_dynamic_spinner3"); - LLSpinCtrl* spinner4 = getChild("tonemapper_dynamic_spinner4"); - LLSpinCtrl* spinner5 = getChild("tonemapper_dynamic_spinner5"); - LLSpinCtrl* spinner6 = getChild("tonemapper_dynamic_spinner6"); - LLSpinCtrl* spinner7 = getChild("tonemapper_dynamic_spinner7"); - - // Init Sliders - LLSliderCtrl* slider1 = getChild("tonemapper_dynamic_slider1"); - LLSliderCtrl* slider2 = getChild("tonemapper_dynamic_slider2"); - LLSliderCtrl* slider3 = getChild("tonemapper_dynamic_slider3"); - LLSliderCtrl* slider4 = getChild("tonemapper_dynamic_slider4"); - LLSliderCtrl* slider5 = getChild("tonemapper_dynamic_slider5"); - LLSliderCtrl* slider6 = getChild("tonemapper_dynamic_slider6"); - LLSliderCtrl* slider7 = getChild("tonemapper_dynamic_slider7"); - - // Check the state of AlchemyRenderTonemapType - /* switch (gSavedSettings.getS32("AlchemyRenderTonemapType")) + if (mVec3Updating || !ctrl) { - default: - { - text1->setVisible(false); - spinner1->setVisible(false); - slider1->setVisible(false); - - text2->setVisible(false); - spinner2->setVisible(false); - slider2->setVisible(false); - - text3->setVisible(false); - spinner3->setVisible(false); - slider3->setVisible(false); - - text4->setVisible(false); - spinner4->setVisible(false); - slider4->setVisible(false); - - text5->setVisible(false); - spinner5->setVisible(false); - slider5->setVisible(false); - - text6->setVisible(false); - spinner6->setVisible(false); - slider6->setVisible(false); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; + return; } - case ALRenderUtil::TONEMAP_UCHIMURA: - { - text1->setVisible(true); - text1->setText(std::string("Max Brightness")); - spinner1->setVisible(true); - spinner1->setMinValue(0.01f); - spinner1->setMaxValue(8.0f); - spinner1->setIncrement(0.1f); - spinner1->setControlName("AlchemyToneMapUchimuraMaxBrightness"); - slider1->setVisible(true); - slider1->setMinValue(0.01f); - slider1->setMaxValue(8.0f); - slider1->setIncrement(0.1f); - slider1->setControlName("AlchemyToneMapUchimuraMaxBrightness", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Contrast")); - spinner2->setVisible(true); - spinner2->setMinValue(0.01f); - spinner2->setMaxValue(2.0f); - spinner2->setIncrement(0.01f); - spinner2->setControlName("AlchemyToneMapUchimuraContrast"); - slider2->setVisible(true); - slider2->setMinValue(0.01f); - slider2->setMaxValue(2.0f); - slider2->setIncrement(0.01f); - slider2->setControlName("AlchemyToneMapUchimuraContrast", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Linear Start")); - spinner3->setVisible(true); - spinner3->setMinValue(0.01f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapUchimuraLinearStart"); - slider3->setVisible(true); - slider3->setMinValue(0.01f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapUchimuraLinearStart", nullptr); - - text4->setVisible(true); - text4->setText(std::string("Linear Length")); - spinner4->setVisible(true); - spinner4->setMinValue(0.01f); - spinner4->setMaxValue(1.0f); - spinner4->setIncrement(0.01f); - spinner4->setControlName("AlchemyToneMapUchimuraLinearLength"); - slider4->setVisible(true); - slider4->setMinValue(0.01f); - slider4->setMaxValue(1.0f); - slider4->setIncrement(0.01f); - slider4->setControlName("AlchemyToneMapUchimuraLinearLength", nullptr); - text5->setVisible(true); - text5->setText(std::string("Black Level")); - spinner5->setVisible(true); - spinner5->setMinValue(0.01f); - spinner5->setMaxValue(4.0f); - spinner5->setIncrement(0.01f); - spinner5->setControlName("AlchemyToneMapUchimuraBlackLevel"); - slider5->setVisible(true); - slider5->setMinValue(0.01f); - slider5->setMaxValue(4.0f); - slider5->setIncrement(0.01f); - slider5->setControlName("AlchemyToneMapUchimuraBlackLevel", nullptr); - - text6->setVisible(false); - spinner6->setVisible(false); - slider6->setVisible(false); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; - } - case ALRenderUtil::TONEMAP_AMD: + std::string setting; + S32 component = 0; + if (!parseVec3WidgetName(ctrl->getName(), setting, component)) { - text1->setVisible(true); - text1->setText(std::string("HDR Max")); - spinner1->setVisible(true); - spinner1->setMinValue(1.0f); - spinner1->setMaxValue(512.0f); - spinner1->setIncrement(1.f); - spinner1->setControlName("AlchemyToneMapAMDHDRMax"); - slider1->setVisible(true); - slider1->setMinValue(1.0f); - slider1->setMaxValue(512.0f); - slider1->setIncrement(1.f); - slider1->setControlName("AlchemyToneMapAMDHDRMax", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Tone Exposure")); - spinner2->setVisible(true); - spinner2->setMinValue(1.0f); - spinner2->setMaxValue(16.0f); - spinner2->setIncrement(0.1f); - spinner2->setControlName("AlchemyToneMapAMDExposure"); - slider2->setVisible(true); - slider2->setMinValue(1.0f); - slider2->setMaxValue(16.0f); - slider2->setIncrement(0.1f); - slider2->setControlName("AlchemyToneMapAMDExposure", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Contrast")); - spinner3->setVisible(true); - spinner3->setMinValue(0.0f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapAMDContrast"); - slider3->setVisible(true); - slider3->setMinValue(0.0f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapAMDContrast", nullptr); - - text4->setVisible(true); - text4->setText(std::string("R Saturation")); - spinner4->setVisible(true); - spinner4->setMinValue(-2.0f); - spinner4->setMaxValue(2.0f); - spinner4->setIncrement(0.1f); - spinner4->setControlName("AlchemyToneMapAMDSaturationR"); - slider4->setVisible(true); - slider4->setMinValue(-2.0f); - slider4->setMaxValue(2.0f); - slider4->setIncrement(0.1f); - slider4->setControlName("AlchemyToneMapAMDSaturationR", nullptr); - - text5->setVisible(true); - text5->setText(std::string("G Saturation")); - spinner5->setVisible(true); - spinner5->setMinValue(-2.0f); - spinner5->setMaxValue(2.0f); - spinner5->setIncrement(0.1f); - spinner5->setControlName("AlchemyToneMapAMDSaturationG"); - slider5->setVisible(true); - slider5->setMinValue(-2.0f); - slider5->setMaxValue(2.0f); - slider5->setIncrement(0.1f); - slider5->setControlName("AlchemyToneMapAMDSaturationG", nullptr); - - text6->setVisible(true); - text6->setText(std::string("B Saturation")); - spinner6->setVisible(true); - spinner6->setMinValue(-2.0f); - spinner6->setMaxValue(2.0f); - spinner6->setIncrement(0.1f); - spinner6->setControlName("AlchemyToneMapAMDSaturationB"); - slider6->setVisible(true); - slider6->setMinValue(-2.0f); - slider6->setMaxValue(2.0f); - slider6->setIncrement(0.1f); - slider6->setControlName("AlchemyToneMapAMDSaturationB", nullptr); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; + return; } - case ALRenderUtil::TONEMAP_UNCHARTED: - { - text1->setVisible(true); - text1->setText(std::string("Toe Strength")); - spinner1->setVisible(true); - spinner1->setMinValue(0.0f); - spinner1->setMaxValue(1.0f); - spinner1->setIncrement(0.01f); - spinner1->setControlName("AlchemyToneMapFilmicToeStr"); - slider1->setVisible(true); - slider1->setMinValue(0.0f); - slider1->setMaxValue(1.0f); - slider1->setIncrement(0.01f); - slider1->setControlName("AlchemyToneMapFilmicToeStr", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Toe Length")); - spinner2->setVisible(true); - spinner2->setMinValue(0.01f); - spinner2->setMaxValue(1.0f); - spinner2->setIncrement(0.01f); - spinner2->setControlName("AlchemyToneMapFilmicToeLen"); - slider2->setVisible(true); - slider2->setMinValue(0.01f); - slider2->setMaxValue(1.0f); - slider2->setIncrement(0.01f); - slider2->setControlName("AlchemyToneMapFilmicToeLen", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Shoulder Strength")); - spinner3->setVisible(true); - spinner3->setMinValue(0.0f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapFilmicShoulderStr"); - slider3->setVisible(true); - slider3->setMinValue(0.0f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapFilmicShoulderStr", nullptr); - - text4->setVisible(true); - text4->setText(std::string("Shoulder Length")); - spinner4->setVisible(true); - spinner4->setMinValue(0.01f); - spinner4->setMaxValue(8.0f); - spinner4->setIncrement(0.01f); - spinner4->setControlName("AlchemyToneMapFilmicShoulderLen"); - slider4->setVisible(true); - slider4->setMinValue(0.01f); - slider4->setMaxValue(8.0f); - slider4->setIncrement(0.01f); - slider4->setControlName("AlchemyToneMapFilmicShoulderLen", nullptr); - text5->setVisible(true); - text5->setText(std::string("Shoulder Angle")); - spinner5->setVisible(true); - spinner5->setMinValue(0.0f); - spinner5->setMaxValue(1.0f); - spinner5->setIncrement(0.01f); - spinner5->setControlName("AlchemyToneMapFilmicShoulderAngle"); - slider5->setVisible(true); - slider5->setMinValue(0.0f); - slider5->setMaxValue(1.0f); - slider5->setIncrement(0.01f); - slider5->setControlName("AlchemyToneMapFilmicShoulderAngle", nullptr); - - text6->setVisible(true); - text6->setText(std::string("Gamma")); - spinner6->setVisible(true); - spinner6->setMinValue(0.01f); - spinner6->setMaxValue(5.0f); - spinner6->setIncrement(0.01f); - spinner6->setControlName("AlchemyToneMapFilmicGamma"); - slider6->setVisible(true); - slider6->setMinValue(0.01f); - slider6->setMaxValue(5.0f); - slider6->setIncrement(0.01f); - slider6->setControlName("AlchemyToneMapFilmicGamma", nullptr); + LLControlVariable* controlp = gSavedSettings.getControl(setting); + if (!controlp) + { + return; + } - text7->setVisible(true); - text7->setText(std::string("White Point")); - spinner7->setVisible(true); - spinner7->setMinValue(1.0f); - spinner7->setMaxValue(16.0f); - spinner7->setIncrement(0.1f); - spinner7->setControlName("AlchemyToneMapFilmicWhitePoint"); - slider7->setVisible(true); - slider7->setMinValue(1.0f); - slider7->setMaxValue(16.0f); - slider7->setIncrement(0.1f); - slider7->setControlName("AlchemyToneMapFilmicWhitePoint", nullptr); - break; + // Rebuild the full component array so a single spinner commit writes back + // one component without disturbing the others; works for VEC3 and COL3. + const LLSD current = controlp->getValue(); + LLSD updated = LLSD::emptyArray(); + for (S32 i = 0; i < 3; ++i) + { + updated.append(LLSD::Real(current[i].asReal())); } - }*/ + updated[component] = LLSD::Real(ctrl->getValue().asReal()); + controlp->set(updated); } -void ALFloaterLightBox::updateCAS() +void ALFloaterLightBox::refreshVec3Row(const std::string& setting_name) { - // Init UI - LLTextBox* text2 = getChild("sharp_dynamic_text"); - LLSpinCtrl* spinner1 = getChild("sharp_strength_spinner"); - LLSpinCtrl* spinner2 = getChild("sharp_dynamic_spinner"); - LLSliderCtrl* slider1 = getChild("sharp_strength_slider"); - LLSliderCtrl* slider2 = getChild("sharp_dynamic_slider"); - - //switch (gSavedSettings.getU32("RenderSharpenMethod")) - //{ - //default: - //case ALRenderUtil::SHARPEN_NONE: - //{ - // spinner1->setVisible(false); - // slider1->setVisible(false); - // text2->setVisible(false); - // spinner2->setVisible(false); - // slider2->setVisible(false); - // break; - //} - //case ALRenderUtil::SHARPEN_CAS: - //{ - // spinner1->setVisible(true); - // spinner1->setMinValue(0.0f); - // spinner1->setMaxValue(1.0f); - // spinner1->setIncrement(0.1f); - // spinner1->setControlName("RenderSharpenCASSharpness"); - // slider1->setVisible(true); - // slider1->setMinValue(0.0f); - // slider1->setMaxValue(1.0f); - // slider1->setIncrement(0.1f); - // slider1->setControlName("RenderSharpenCASSharpness", nullptr); - - // text2->setVisible(false); - // spinner2->setVisible(false); - // slider2->setVisible(false); - // break; - //} - //case ALRenderUtil::SHARPEN_DLS: - //{ - // spinner1->setVisible(true); - // spinner1->setMinValue(0.0f); - // spinner1->setMaxValue(1.0f); - // spinner1->setIncrement(0.1f); - // spinner1->setControlName("RenderSharpenDLSSharpness"); - // slider1->setVisible(true); - // slider1->setMinValue(0.0f); - // slider1->setMaxValue(1.0f); - // slider1->setIncrement(0.1f); - // slider1->setControlName("RenderSharpenDLSSharpness", nullptr); + auto row = mVec3Rows.find(setting_name); + LLControlVariable* controlp = gSavedSettings.getControl(setting_name); + if (row == mVec3Rows.end() || !controlp) + { + return; + } - // text2->setVisible(true); - // text2->setText(std::string("Denoise:")); - // spinner2->setVisible(true); - // spinner2->setMinValue(0.0f); - // spinner2->setMaxValue(1.0f); - // spinner2->setIncrement(0.1f); - // spinner2->setControlName("RenderSharpenDLSDenoise"); - // slider2->setVisible(true); - // slider2->setMinValue(0.0f); - // slider2->setMaxValue(1.0f); - // slider2->setIncrement(0.1f); - // slider2->setControlName("RenderSharpenDLSDenoise", nullptr); - // break; - //} - //} + const LLSD value = controlp->getValue(); + mVec3Updating = true; + for (S32 i = 0; i < 3; ++i) + { + if (LLSpinCtrl* spinnerp = row->second[i]) + { + spinnerp->setValue(value[i].asReal()); + } + } + mVec3Updating = false; } diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index e53e886e5b..92b34d5a87 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -1,6 +1,6 @@ /** * @file alfloaterlightbox.h - * @brief A generic text floater for dumping info (usually debug info) + * @brief Lightbox post-processing control floater * * Copyright (c) Rye Mutt * @@ -34,7 +34,13 @@ #define AL_FLOATERLIGHTBOX_H #include "llfloater.h" + +#include +#include #include +#include + +class LLSpinCtrl; class ALFloaterLightBox final : public LLFloater { @@ -42,17 +48,20 @@ class ALFloaterLightBox final : public LLFloater ALFloaterLightBox(const LLSD& key); ~ALFloaterLightBox() override; bool postBuild() override; - virtual void draw() override; private: void onClickResetControlDefault(const LLSD& userdata); - void onClickResetGroupDefault(const LLSD& userdata); - void updateTonemapper(); - void updateCAS(); + void onClickResetSection(const LLSD& userdata); + void onCommitVec3(LLUICtrl* ctrl); + void refreshVec3Row(const std::string& setting_name); void populateLUTCombo(); - boost::signals2::scoped_connection mTonemapConnection; - boost::signals2::scoped_connection mCASConnection; + // Spinner triplets named "vec3__<0|1|2>", keyed by setting name. + // Rows are discovered by walking the widget tree in postBuild; adding a + // vector-valued row is pure XUI. + std::map> mVec3Rows; + std::vector mVec3Connections; + bool mVec3Updating = false; }; #endif // AL_FLOATERLIGHTBOX_H diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index d1fb354499..8bcbec79b9 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -10065,7 +10065,7 @@ Comment Use exposure sky settings instead of deriving from HDR scale. Persist - 0 + 1 Type Boolean Value @@ -10076,12 +10076,45 @@ Comment Luminance coefficient for dynamic exposure Persist - 0 + 1 Type F32 Value 0.5 + RenderDynamicExposureEnabled + + Comment + Enable dynamic exposure adjustment (auto-exposure) when HDR rendering is enabled + Persist + 1 + Type + Boolean + Value + 1 + + RenderDynamicExposureSpeedError + + Comment + Speed at which dynamic exposure adapts while far from the target exposure + Persist + 1 + Type + F32 + Value + 0.1 + + RenderDynamicExposureSpeedTarget + + Comment + Speed at which dynamic exposure settles once near the target exposure + Persist + 1 + Type + F32 + Value + 2.0 + RenderDiffuseLuminanceScale Comment diff --git a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml index ec4e8165f6..d4af62bce2 100644 --- a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml +++ b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml @@ -1,2595 +1,58 @@ + width="460" + height="560" + min_width="420" + min_height="420" + layout="topleft" + name="floater_lightbox_settings" + positioning="cascading" + title="Lightbox" + save_rect="true" + can_resize="true"> + + + - + follows="all" + layout="topleft" + left="4" + top_pad="2" + right="-4" + height="512" + name="lightbox_tabs" + tab_position="top" + tab_min_width="70"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_look.xml" + label="Look" + layout="topleft" + name="tab_look" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_lens.xml" + label="Lens" + layout="topleft" + name="tab_lens" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_scene.xml" + label="Scene" + layout="topleft" + name="tab_scene" /> diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml new file mode 100644 index 0000000000..10970f29a8 --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml @@ -0,0 +1,19 @@ + + + + Lens effects (depth of field, bloom and halation, lens flare, chromatic aberration, vignette, film grain, sharpening) will appear here. + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml new file mode 100644 index 0000000000..f13e5d675f --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -0,0 +1,1110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml new file mode 100644 index 0000000000..414288ef3d --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml @@ -0,0 +1,19 @@ + + + + Scene quality settings (anti-aliasing, reflections, shadows and ambient occlusion, performance, preview modes) will appear here. + + From 35e44453f54d8e060580330c5949defd2588e79c Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:20:25 -0500 Subject: [PATCH 02/32] Fix accordion section clipping and grey inactive tonemapper params An accordion_tab rect is its expand height, and fit_panel squeezes the inner panel into what remains below the 25px header plus 2+2 padding. The sections sized tab == panel, so every section lost its bottom 29px and the tall Advanced tab drew its overflow across the sections below it (panels do not clip children). Tabs are now panel height + 29 and the contract is documented in the panel header comment. Per-operator tonemapper rows (ACES Boosted, Reinhard, Filmic, AgX white points and contrast) previously stayed editable regardless of the selected operator; they and their reset buttons now enable only while their operator is active, driven by a connection on AlchemyRenderTonemapType. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/alfloaterlightbox.cpp | 25 +++++++++++++++++++ indra/newview/alfloaterlightbox.h | 2 ++ .../default/xui/en/panel_lightbox_look.xml | 13 ++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 2e1f3ffb64..62ff8b6922 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -37,6 +37,7 @@ #include "llviewercontrol.h" #include +#include namespace { @@ -124,6 +125,10 @@ bool ALFloaterLightBox::postBuild() { populateLUTCombo(); + mTonemapConnection = gSavedSettings.getControl("AlchemyRenderTonemapType")->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { updateTonemapperRows(); }); + updateTonemapperRows(); + collectVec3Spinners(this, mVec3Rows); for (const auto& row : mVec3Rows) { @@ -255,6 +260,26 @@ void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) controlp->set(updated); } +void ALFloaterLightBox::updateTonemapperRows() +{ + // Khronos Neutral (0), ACES (1), and GT (5) take no parameters, so their + // selection leaves every per-operator row disabled. + const S32 type = gSavedSettings.getS32("AlchemyRenderTonemapType"); + static const std::pair param_rows[] = { + { "tone_aces_white", 2 }, + { "tone_reinhard_white", 3 }, + { "tone_filmic_white", 4 }, + { "tone_agx_contrast", 6 }, + { "tone_agx_white", 6 }, + }; + for (const auto& row : param_rows) + { + const bool active = (type == row.second); + getChild(row.first)->setEnabled(active); + getChild(std::string(row.first) + "_rst")->setEnabled(active); + } +} + void ALFloaterLightBox::refreshVec3Row(const std::string& setting_name) { auto row = mVec3Rows.find(setting_name); diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 92b34d5a87..ac053e361b 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -55,12 +55,14 @@ class ALFloaterLightBox final : public LLFloater void onCommitVec3(LLUICtrl* ctrl); void refreshVec3Row(const std::string& setting_name); void populateLUTCombo(); + void updateTonemapperRows(); // Spinner triplets named "vec3__<0|1|2>", keyed by setting name. // Rows are discovered by walking the widget tree in postBuild; adding a // vector-valued row is pure XUI. std::map> mVec3Rows; std::vector mVec3Connections; + boost::signals2::scoped_connection mTonemapConnection; bool mVec3Updating = false; }; diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index f13e5d675f..f8d8b32943 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -7,6 +7,9 @@ Row conventions: color = (0-1 tints only) section = "sec_"; long tail in sibling "sec__adv"; "Reset All" resets every bound control in the section (essential sections include their Advanced tab) + heights = accordion_tab height MUST be its inner panel height + 29 (25 header + 2+2 padding); + the tab's rect is the expand height and fit_panel squeezes the panel into what is + left after the header, so an undersized tab clips the bottom rows --> @@ -191,7 +194,7 @@ Row conventions: @@ -516,7 +519,7 @@ Row conventions: @@ -739,7 +742,7 @@ Row conventions: @@ -843,7 +846,7 @@ Row conventions: From 6c99be8502662969e7f17ceecb1e513684ed8812 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:42:31 -0500 Subject: [PATCH 03/32] Size the Advanced tone tab to its real content and unclip the swatch The advanced tab was 32px short: slider rows chain top_pad from their reset button, which hangs 1px below the slider, so slider+reset rows pitch 26px rather than the 25 the original sum assumed. Content ends at 290, giving panel 291 / tab 320 (in-viewer verified). The height comment now spells out the row-pitch rule. color_swatch reserves a label strip below the color area by default (label_height -1), which left a 24px-tall swatch roughly one pixel of color; label_height="0" gives the color the full rect, matching the environment-adjust floater swatches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../skins/default/xui/en/panel_lightbox_look.xml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index f8d8b32943..c4523af82e 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -9,7 +9,10 @@ Row conventions: every bound control in the section (essential sections include their Advanced tab) heights = accordion_tab height MUST be its inner panel height + 29 (25 header + 2+2 padding); the tab's rect is the expand height and fit_panel squeezes the panel into what is - left after the header, so an undersized tab clips the bottom rows + left after the header, so an undersized tab clips the bottom rows. Compute panel + height from the LAST widget's bottom: top_pad chains from the previous widget, + which for a slider row is its reset button (hangs 1px below the slider), so + slider+reset rows pitch 26px, not 25 --> From 24a66ef245cb1fcac736deb2cd47829572642f8e Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:02:29 -0500 Subject: [PATCH 04/32] Treat missing alpha as opaque when a color swatch reads a Color3 LLColor4(LLSD) reads element 3 of a 3-element Color3 array as 0, so any color_swatch bound to a Color3 control drew fully transparent (checkerboard) even though the RGB round-trip worked. Both the Lightbox tint swatches and the Debug Settings floater feed swatches through setValue, so forcing alpha to 1 for 3-element arrays at that single chokepoint fixes the display everywhere. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/llcolorswatch.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/indra/newview/llcolorswatch.cpp b/indra/newview/llcolorswatch.cpp index 07f8a8bec4..21abd317ab 100644 --- a/indra/newview/llcolorswatch.cpp +++ b/indra/newview/llcolorswatch.cpp @@ -301,7 +301,15 @@ void LLColorSwatchCtrl::setEnabled( bool enabled ) void LLColorSwatchCtrl::setValue(const LLSD& value) { - set(LLColor4(value), true, true); + LLColor4 color(value); + // A 3-element array (Color3 control) carries no alpha; LLColor4(LLSD) reads + // the missing element as 0 and the swatch draws fully transparent. Treat + // missing alpha as opaque. + if (value.isArray() && value.size() == 3) + { + color.mV[VALPHA] = 1.f; + } + set(color, true, true); } ////////////////////////////////////////////////////////////////////////////// From 15daecf8ce783bf0f7da8a74ab41242ded52ec23 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:15:17 -0500 Subject: [PATCH 05/32] Keep Color3 swatches opaque and stop persisting a stale alpha The previous fix only forced alpha for 3-element values, but the first pick made under alpha 0 had already written a 4-element array into the Color3 control ("keep current alpha" in onColorChanged), so the stored value carried an explicit 0 that the size==3 guard never caught, and every later pick preserved it. Display now also treats any swatch bound to a TYPE_COL3 control as opaque, and the picker write path stores only three components for such controls, healing poisoned values on the next commit. Debug Settings feeds its swatch the parsed 3-element color instead of the raw stored LLSD so its display is shape-independent too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/llcolorswatch.cpp | 28 +++++++++++++++++++----- indra/newview/llfloatersettingsdebug.cpp | 2 +- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/indra/newview/llcolorswatch.cpp b/indra/newview/llcolorswatch.cpp index 21abd317ab..e98f76b49a 100644 --- a/indra/newview/llcolorswatch.cpp +++ b/indra/newview/llcolorswatch.cpp @@ -30,6 +30,8 @@ #include "llcolorswatch.h" // Linden library includes +#include "llcontrol.h" +#include "v3color.h" #include "v4color.h" #include "llwindow.h" // setCursor() @@ -302,10 +304,16 @@ void LLColorSwatchCtrl::setEnabled( bool enabled ) void LLColorSwatchCtrl::setValue(const LLSD& value) { LLColor4 color(value); - // A 3-element array (Color3 control) carries no alpha; LLColor4(LLSD) reads - // the missing element as 0 and the swatch draws fully transparent. Treat - // missing alpha as opaque. - if (value.isArray() && value.size() == 3) + // Color3 controls carry no meaningful alpha: a 3-element array reads as + // alpha 0 through LLColor4(LLSD), and a round-trip through the picker can + // persist that stale 0 as a 4th element. Show such colors opaque. + bool opaque = value.isArray() && value.size() == 3; + if (!opaque) + { + LLControlVariable* controlp = getControlVariable(); + opaque = controlp && controlp->type() == TYPE_COL3; + } + if (opaque) { color.mV[VALPHA] = 1.f; } @@ -332,7 +340,17 @@ void LLColorSwatchCtrl::onColorChanged ( void* data, EColorPickOp pick_op ) if (color_changed) { subject->mColor = updatedColor; - subject->setControlValue(updatedColor.getValue()); + LLControlVariable* controlp = subject->getControlVariable(); + if (controlp && controlp->type() == TYPE_COL3) + { + // Color3 controls store three components; writing the + // swatch's four would persist a meaningless alpha. + subject->setControlValue(LLColor3(updatedColor).getValue()); + } + else + { + subject->setControlValue(updatedColor.getValue()); + } } if (pick_op == COLOR_CANCEL && subject->mOnCancelCallback) diff --git a/indra/newview/llfloatersettingsdebug.cpp b/indra/newview/llfloatersettingsdebug.cpp index a820cee148..600b150db2 100644 --- a/indra/newview/llfloatersettingsdebug.cpp +++ b/indra/newview/llfloatersettingsdebug.cpp @@ -479,7 +479,7 @@ void LLFloaterSettingsDebug::updateControl(LLControlVariable* controlp) LLColor3 clr; clr.setValue(sd); mColorSwatch->setVisible(true); - mColorSwatch->setValue(sd); + mColorSwatch->setValue(clr.getValue()); break; } // [RLVa:KB] - Patch: RLVa-2.1.0 From 7741ccced7abfe6fbff3ced069f66cf53caace72 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:26:57 -0500 Subject: [PATCH 06/32] Complete the Look tab: grade suite, white balance, split toning, curves Adds the Basic Grade section (brightness/contrast/saturation/vibrance, with highlights/shadows/black point/white point/hue shift under Advanced), White Balance (CCT and Duv), the rest of Split Toning (highlight tint, balance; midtone tint and amount under Advanced), and the per-channel Tone Curve (toe/shoulder/strength as Vec3 spinner rows). Ranges and tooltips come from the setting Comments. populateLUTCombo now scans the bundled app_settings/colorlut directory as well as the user colorlut directory (separator between them, matching the order the renderer resolves names in), replacing the hardcoded bundled item list so newly shipped LUTs appear without XML edits; bundled entries are labeled by filename stem. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/alfloaterlightbox.cpp | 44 +- .../default/xui/en/panel_lightbox_look.xml | 1055 +++++++++++++++-- 2 files changed, 956 insertions(+), 143 deletions(-) diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 62ff8b6922..41a876db45 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -150,31 +150,20 @@ bool ALFloaterLightBox::postBuild() void ALFloaterLightBox::populateLUTCombo() { LLComboBox* lut_combo = getChild("colorlut_combo"); - const std::string& user_luts = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); - std::error_code ec; - std::filesystem::path user_luts_path = fsyspath(user_luts); - if(std::filesystem::is_directory(user_luts_path, ec)) + auto add_luts_from = [lut_combo](const std::string& dir_name) { - if(ec) + std::error_code ec; + std::filesystem::path luts_path = fsyspath(dir_name); + if (!std::filesystem::is_directory(luts_path, ec) || ec) { - LL_WARNS() << "Error checking user LUTs directory: " << ec.message() << LL_ENDL; return; } - if(!std::filesystem::is_empty(user_luts_path, ec) && !ec) - { - if(ec) - { - LL_WARNS() << "Error checking contents of user LUTs directory: " << ec.message() << LL_ENDL; - return; - } - lut_combo->addSeparator(); - } - for (std::filesystem::directory_iterator lut(user_luts_path, ec); lut != std::filesystem::directory_iterator(); ++lut) + for (std::filesystem::directory_iterator lut(luts_path, ec); lut != std::filesystem::directory_iterator(); ++lut) { - if(ec) + if (ec) { - LL_WARNS() << "Error reading user LUT file: " << ec.message() << LL_ENDL; + LL_WARNS() << "Error reading LUT file in " << dir_name << ": " << ec.message() << LL_ENDL; continue; } #if LL_WINDOWS @@ -186,9 +175,24 @@ void ALFloaterLightBox::populateLUTCombo() #endif lut_combo->add(lut_stem, lut_filename); } - lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); - lut_combo->resetDirty(); + }; + + // Bundled LUTs first, then user LUTs behind a separator — the same order + // the renderer resolves a name in, where the user dir wins. + add_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "colorlut")); + + const std::string& user_luts = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); + std::error_code ec; + std::filesystem::path user_luts_path = fsyspath(user_luts); + if (std::filesystem::is_directory(user_luts_path, ec) && !ec && + !std::filesystem::is_empty(user_luts_path, ec) && !ec) + { + lut_combo->addSeparator(); + add_luts_from(user_luts); } + + lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); + lut_combo->resetDirty(); } void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index c4523af82e..7a9dfe6364 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -571,106 +571,6 @@ Row conventions: label="None" name="lut_none" value="" /> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + name="wb_duv" + tool_tip="Green/magenta tint, perpendicular to temperature. Negative pushes magenta, positive pushes green." + control_name="RenderColorGradeWhiteBalanceDuv" /> + + + + + + + + + + + + + + + @@ -1113,5 +1656,271 @@ Row conventions: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6eaebfdca4c541893017b6e8b87fd632719c76a2 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:37:01 -0500 Subject: [PATCH 07/32] Fold the split toning midtone rows into the main section Two advanced rows did not justify a separate accordion tab; midtone tint and amount now live at the bottom of Split Toning and the Advanced sibling is gone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../default/xui/en/panel_lightbox_look.xml | 47 +++---------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index 7a9dfe6364..e2f62c6bbf 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -1113,14 +1113,14 @@ Row conventions: - - - - - + name="sec_split_reset" + tool_tip="Reset this section to defaults"> + parameter="sec_split" /> From 771ccce9ecabf5b9ead284472a18d5d287a17ecf Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:55:01 -0500 Subject: [PATCH 08/32] Build out the Lens tab: DoF, bloom/glow, flare, CA, vignette, grain Fourteen accordion sections covering the optical effects. Depth of field, HDR bloom, legacy glow, lens flare, chromatic aberration, and vignette each pair an essentials section with an Advanced sibling; film grain and sharpen/dither fold their short tails into a single section. The bloom/glow pair expresses the HDR fork by greying: HDR bloom rows enable on RenderBloomHDR, legacy glow rows on its inverse, halation rows on RenderBloomHalation, and the DoF parameter rows on RenderDepthOfField. Glow luminance/warmth weights and the vignette center ride the existing Vec3 spinner binder; no new C++. Ranges, defaults, and tooltip text come from the setting Comments. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../default/xui/en/panel_lightbox_lens.xml | 3213 ++++++++++++++++- 1 file changed, 3203 insertions(+), 10 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml index 10970f29a8..9279ed7230 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml @@ -1,19 +1,3212 @@ + - - Lens effects (depth of field, bloom and halation, lens flare, chromatic aberration, vignette, film grain, sharpening) will appear here. - + left="2" + top="2" + right="-2" + height="484" + single_expansion="false" + name="lens_accordion"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Applies when HDR bloom is off. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 92614d2a634e443034497451dbbd43625ebfad4c Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:15:21 -0500 Subject: [PATCH 09/32] Polish the Lens tab from first review feedback Bloom base resolution becomes a dropdown (Full / 3/4 / Half / Quarter / Eighth) instead of a bare 0-4 slider. Lens flare advanced groups get bold headers with divider lines so the categories read at a glance. Chromatic aberration merges into a single section since its essentials were one slider. Film grain "Range" is relabeled "Luma range". The dither toggles leave the floater entirely - dithering is a de-banding feature that should only ever be disabled for debugging, so it stays Debug Settings-only - and the section is now just "Sharpen". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../default/xui/en/panel_lightbox_lens.xml | 194 ++++++++++-------- 1 file changed, 105 insertions(+), 89 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml index 9279ed7230..2cec9c228c 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml @@ -619,31 +619,54 @@ without layout holes. function="LightBox.ResetControlDefault" parameter="RenderBloomMipCount" /> - + + control_name="RenderBloomResolutionScale"> + + + + + + + + + + + - - - - - + name="sec_ca_reset" + tool_tip="Reset this section to defaults"> + parameter="sec_ca" /> @@ -3023,7 +3062,7 @@ without layout holes. top_pad="9" right="-32" height="16" - label="Range" + label="Luma range" label_width="140" can_edit_text="true" decimal_digits="2" @@ -3120,14 +3159,14 @@ without layout holes. - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a2517887c4fc7ff008d94365a98a3a57cded0fba Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:03:09 -0500 Subject: [PATCH 13/32] Streamline the Scene tab from review feedback Anti-Aliasing loses its Advanced sibling (the SMAA predication family is default-tuned and stays Debug Settings-only). Mirrors gets its own section holding the toggle plus resolution and update rate, moved out of the reflections Advanced tab; probe Coverage moves up into the Reflections essentials. The Output section is removed entirely - the high-precision and 10-bit flags are too touchy to expose. Shadow resolution scale becomes an x2 / x1 / x0.5 dropdown instead of a slider so rapid drags cannot thrash shadow-map reallocation (handleShadowsResized fires per change). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../default/xui/en/panel_lightbox_scene.xml | 444 ++++++------------ 1 file changed, 139 insertions(+), 305 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml index ebd997fd50..48a7a7f347 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml @@ -148,172 +148,13 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. image_overlay="Refresh_Off" image_overlay_alignment="right" name="sec_aa_reset" - tool_tip="Reset this section (including Advanced) to defaults"> + tool_tip="Reset this section to defaults"> - - - - - - - - - - - - - - + top_pad="9" + width="140" + height="15" + name="reflect_level_label" + value="Coverage" /> + + + + + + + - + + + + + + + name="mirrors_res_label" + value="Resolution" /> @@ -855,7 +731,7 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. scale_image="true" image_overlay="Refresh_Off" image_overlay_alignment="center" - name="reflect_mirror_res_rst" + name="mirrors_res_rst" tool_tip="Reset to default"> + name="mirrors_rate_label" + value="Update rate" /> @@ -908,7 +784,7 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. scale_image="true" image_overlay="Refresh_Off" image_overlay_alignment="center" - name="reflect_mirror_rate_rst" + name="mirrors_rate_rst" tool_tip="Reset to default"> + name="sec_mirrors_reset" + tool_tip="Reset this section to defaults"> + parameter="sec_mirrors" /> @@ -997,30 +873,45 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. function="LightBox.ResetControlDefault" parameter="RenderShadowDetail" /> - + + height="18" + name="shadow_res_scale_combo" + tool_tip="Shadow map resolution multiplier. Each change reallocates the shadow maps; x2 can exhaust VRAM on limited GPUs." + control_name="RenderShadowResolutionScale"> + + + + - - From a635f94d6841a3eb36822e8a5349990f354f8d97 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:10:23 -0500 Subject: [PATCH 14/32] Expose the SSAO effect vector by its real semantics RenderSSAOEffect is a value multiplier, a saturation multiplier (HSV, for fully occluded areas, blending with the original color under partial occlusion), and an unused third component - so the generic X/Y/Z spinner triplet becomes two labeled rows, "Occluded value" and "Occluded saturation", each a single component spinner through the Vec3 binder. The unused component is no longer exposed (the binder preserves it on write), both rows grey with SSAO off, and the single reset glyph restores the whole vector. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../default/xui/en/panel_lightbox_scene.xml | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml index 48a7a7f347..284d104d08 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml @@ -1003,14 +1003,14 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. + name="ssao_effect_value_label" + value="Occluded value" /> - - - - - - + name="vec3_RenderSSAOEffect_0" + tool_tip="Brightness (HSV value) multiplier for fully occluded areas; partial occlusion blends toward the original color" + enabled_control="RenderDeferredSSAO"> @@ -1411,16 +1377,43 @@ buffer reallocation, and a shader rebuild); it stays a prefs-level switch. height="18" width="18" right="-8" - top_delta="0" + top_pad="-18" scale_image="true" image_overlay="Refresh_Off" image_overlay_alignment="center" name="ssao_effect_rst" - tool_tip="Reset to default"> + tool_tip="Reset the occlusion effect (both components) to default"> + + + + + + + + Save Graphic Preset + Save Camera Preset + Save Look + Poser Presets self.path("poses") From 48b0cba9a1d5f0ac7851be47c75065beefeee92d Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:55:09 -0500 Subject: [PATCH 18/32] Give the Looks Save As dialog a clean name field The dialog prefills its name combo with existing preset names, and the combo text entry autocompletes typed prefixes to existing items - so typing a new Look name could silently commit to an existing one and overwrite it (observed against a bundled starter). For the looks subdirectory the dialog now opens with an empty, list-free name field and skips list-change repopulation, so typed names save verbatim; deliberate overwriting is what the Lightbox bar Save button is for. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- indra/newview/llfloatersaveprefpreset.cpp | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/indra/newview/llfloatersaveprefpreset.cpp b/indra/newview/llfloatersaveprefpreset.cpp index 0cbf436585..8b6237152e 100644 --- a/indra/newview/llfloatersaveprefpreset.cpp +++ b/indra/newview/llfloatersaveprefpreset.cpp @@ -83,8 +83,21 @@ void LLFloaterSavePrefPreset::onOpen(const LLSD& key) setTitle(getString(title_type)); } - EDefaultOptions option = DEFAULT_HIDE; - LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); + if (PRESETS_LOOKS == mSubdirectory) + { + // A clean name field for Looks: prefilled names invite silent + // overwrites because the combo's text entry autocompletes typed names + // to existing items. Overwriting deliberately is what the Save button + // on the Lightbox bar is for. + mPresetCombo->removeall(); + mPresetCombo->clear(); + mPresetCombo->setEnabled(true); + } + else + { + EDefaultOptions option = DEFAULT_HIDE; + LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); + } onPresetNameEdited(); } @@ -112,6 +125,11 @@ void LLFloaterSavePrefPreset::onBtnSave() void LLFloaterSavePrefPreset::onPresetsListChange() { + if (PRESETS_LOOKS == mSubdirectory) + { + // Looks keep a clean name field; don't repopulate over typed text. + return; + } EDefaultOptions option = DEFAULT_HIDE; LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); } From c08cd549b5a5f6d188349e67f68465f9c7525240 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:03:13 -0500 Subject: [PATCH 19/32] Surface that RenderColorGrade gates the whole grading suite RenderColorGrade is not a LUT toggle: the COLOR_GRADE shader permutation carries the entire grading chain, so Basic Grade, White Balance, Split Toning, Lift/Gamma/Gain, and Tone Curve all no-op while it is off (with it on and no LUT selected, the LUT path is disabled by zeroing its strength, so the combination is safe). The section is now titled Color Grading, the checkbox says "Enable color grading", and every grading row across all five sections greys against it so the dependency is visible instead of a silent no-op. The Soft Film and Golden Hour starters now switch grading on - as shipped they set grade values that the master toggle left inert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeuFfMeQj2RNdoJ5bxfHXr --- .../app_settings/looks/Golden%20Hour.xml | 2 +- .../app_settings/looks/Soft%20Film.xml | 2 +- .../default/xui/en/panel_lightbox_look.xml | 41 +++++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/indra/newview/app_settings/looks/Golden%20Hour.xml b/indra/newview/app_settings/looks/Golden%20Hour.xml index 054f4bc159..f915378b23 100644 --- a/indra/newview/app_settings/looks/Golden%20Hour.xml +++ b/indra/newview/app_settings/looks/Golden%20Hour.xml @@ -212,7 +212,7 @@ Type Boolean Value - 0 + 1 RenderColorGradeBlackPoint diff --git a/indra/newview/app_settings/looks/Soft%20Film.xml b/indra/newview/app_settings/looks/Soft%20Film.xml index ef3eae5f7d..7dc48afcad 100644 --- a/indra/newview/app_settings/looks/Soft%20Film.xml +++ b/indra/newview/app_settings/looks/Soft%20Film.xml @@ -212,7 +212,7 @@ Type Boolean Value - 0 + 1 RenderColorGradeBlackPoint diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index fd8da7aa52..cc25e07c56 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -524,7 +524,7 @@ Row conventions: layout="topleft" height="137" name="atab_sec_lut" - title="Color LUT" + title="Color Grading" fit_panel="true"> + + +``` + +The essentials section's Reset All resets the Advanced sibling too (the walker +includes `sec__adv`); the sibling's own button uses `parameter="sec_myfx_adv"`. + +### 4. Rows + +First row uses `top="8"`; later rows chain with `top_pad`. Every value row gets +an 18px reset glyph. Copy these verbatim and edit names/keys/ranges: + +**Scalar (slider) row** — `top_pad="9"` between slider rows: + +```xml + + +``` + +**Checkbox row** (no reset glyph): `check_box` with `control_name`, +`top_pad="10"` after a button, `top_pad="8"` after another checkbox. + +**Enum dropdown row**: label `text` (width 140) + `combo_box` at +`left_delta="140" top_pad="-16" right="-32" height="18"` with integer +`combo_box.item` values + reset button at `top_pad="-18"`. Float-valued combos +work only with values whose `%lg` stringification is exact ("2", "1", "0.5"). + +**Color row** (0-1 tints only): label `text` (`top_pad="12"`) + `color_swatch` +at `left_delta="140" top_pad="-18" width="60" height="24"` with +`can_apply_immediately="true"` and **`label_height="0"`** (without it the +default label strip leaves ~1px of color) + reset at `top_pad="-21"`. Color3 +alpha handling lives in the widget — nothing else needed. + +**Vector row**: label `text` (width 110, `top_pad="10"`) + spinners named +`vec3__<0|1|2>` at `left_delta="110" top_pad="-16" width="68" +height="18"` (then `left_delta="72" top_delta="0"`), each with per-axis +`min_val`/`max_val` and ``, ++ one reset glyph (`top_delta="0"`, parameter = the setting). Setting names +contain no underscores, so the name parse is unambiguous. Omit components that +are unused — label the ones you keep by meaning. + +**Group headers** (inside a long Advanced panel): a `view_border` +(`bevel_style="none" height="0"`, `top_pad="12"`) then a bold `text` +(`font="SansSerifBold"`, `top_pad="6"`); the first group needs no border. + +### 5. Height math (the part everyone gets wrong) + +- `accordion_tab` height **must be** inner panel height **+ 29** + (25px header + 2+2 padding). The tab's rect *is* its expand height and + `fit_panel` squeezes the panel into what remains; an undersized tab clips + the bottom rows and the overflow draws over the sections below (panels do + not clip children). +- Compute the panel height by walking the `top_pad` chain to the **last + widget's bottom**, then add 8. `top_pad` chains from the *previous widget*, + which for a slider row is its reset button (18px tall, hanging 1px below the + 16px slider) — so slider+reset rows pitch **26px**, not 25. +- Worked example: slider row at `top="8"` (slider 8-24, button 7-25), second + slider `top_pad="9"` (34-50, button 33-51), Reset All `top_pad="10"` + (61-79) → panel height 87, tab height 116. + +### 6. Gating + +`enabled_control="SomeBool"` / `disabled_control="SomeBool"` on each dependent +widget greys it live (they connect to the control's signal). Boolean controls +only; apply per row, not on the parent panel. Reference patterns: + +- HDR fork: bloom rows `enabled_control="RenderHDREnabled"`, legacy glow rows + `disabled_control="RenderHDREnabled"` — greying, not visibility, so the + layout never gets holes and both modes stay discoverable. +- **`RenderColorGrade` is the master switch for the entire grading suite** + (LUT *and* Basic Grade, White Balance, Split Toning, Lift/Gamma/Gain, Tone + Curve). Any new grading control must gate on it or it will look inert. +- Int-selected modes can't gate declaratively; either leave rows enabled with + a "(X only)" tooltip or add a small signal handler like + `updateTonemapperRows()`. + +### 7. Slider text width + +Any slider with `max_val` below 1.0 (or ≤ 0) **must** set an explicit +`text_width` (56 fits a signed 4-decimal value). Without it `LLSliderCtrl` +auto-sizes the value box from `log10(max_value)` and truncates the number. + +### 8. Cadence and tooltips + +- Most keys are read per-frame: live preview, nothing to say. +- Keys wired to reallocation/rebuild handlers in `llviewercontrol.cpp` still + apply automatically but hitch — say so: "Changing causes a brief hitch." / + "Toggling rebuilds shaders (brief hitch)." +- Keys with **no** handler need a restart note, or better, don't expose them. +- Check with: `grep indra/newview/llviewercontrol.cpp`. + +### 9. Looks whitelist + +If the new effect is **aesthetic** (Look/Lens material), add its keys to +`getLooksControlNames()` in `indra/newview/llpresetsmanager.cpp` — the single +source of truth for save, dirty-watching, and the whitelist-filtered apply. +Skip it and Looks silently won't carry the effect. Do **not** add: Scene-tab +keys, `Persist=0` keys, structural buffer-shape knobs, or debug toggles. +A startup `LL_WARNS("Presets")` fires for whitelist names that stop existing, +so renames get caught. + +Bundled starter Looks live in `app_settings/looks/` as full whitelist +snapshots ({Comment, Persist, Type, Value} per key, URI-escaped filenames, +seeded into the user presets dir on first run). To refresh them after adding +keys: tune and save the Look in the viewer, then copy the saved file from +`/presets/looks/` over the bundled one. + +### 10. Verify + +- XML well-formedness before launching (any XML-capable tool). +- Two-way binding: move the row, watch the key in Debug Settings; edit the key + there, watch the row follow. +- Gating flips live; section Reset All touches exactly the section's keys + (including its Advanced sibling); the tab opens to full height with nothing + clipped or drawing over the next section. +- If added to the whitelist: save a Look, change the setting (dirty `*` + appears), re-apply (value returns). +- **Developer-build staging trap:** non-package builds do not restage XUI or + `app_settings` next to the executable. After editing, copy the changed files + into `build-.../newview//skins/...` and `.../app_settings/...` or + the viewer keeps loading the stale copies. diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index cc25e07c56..6fb3e6fc8d 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -1,6 +1,7 @@ + + [NAME] * + + tool_tip="Apply a saved Look (a bundle of the aesthetic settings from the Look and Lens tabs). A * after the name means the settings have been changed since that Look was applied."> - + @@ -60,11 +64,13 @@ layout="topleft" left_pad="4" top="1" - width="60" + width="20" height="20" - label="Save As" + label="" + halign="center" + image_overlay="Conv_toolbar_plus" name="look_saveas" - tool_tip="Save the current settings as a new Look"> + tool_tip="Save As: save the current settings as a new Look"> @@ -73,9 +79,11 @@ layout="topleft" left_pad="4" top="1" - width="36" + width="20" height="20" - label="Del" + label="" + halign="center" + image_overlay="TrashItem_Off" name="look_delete" tool_tip="Delete a saved Look"> + tool_tip="Revert: discard changes and re-apply the last applied Look"> + + + + + + Measuring... + + + Crushed [LOW]% Blown [HIGH]% ([SAMPLES] samples) + + + Crushed [LOW]% Blown [HIGH]% R [R] G [G] B [B] + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml index bf6c6348fb..499dab1c1b 100644 --- a/indra/newview/skins/default/xui/en/menu_viewer.xml +++ b/indra/newview/skins/default/xui/en/menu_viewer.xml @@ -861,6 +861,16 @@ function="Floater.Show" parameter="360capture" /> + + + + + +That spot is too dark to read a colour from. Pick something well lit that should look white or grey. + + + +Temperature and tint cannot neutralise that. It looks like a coloured surface rather than a neutral one under coloured light. + + + +A Look called '[NAME]' already exists. Replace it? + +This cannot be undone. + confirm + + + + name="sec_grading"> + - + + + + + + + + + + + + + + + + + + + + + + - + text_width="56" + name="reference_position" + tool_tip="Where the seam sits across the frame: the still to its left, the live image to its right" + control_name="RenderReferenceWipePosition" /> + name="sec_basic"> + + control_name="RenderColorGradeWhiteBalanceCCT" /> + control_name="RenderColorGradeWhiteBalanceDuv" /> + + + + control_name="RenderColorGradeContrast" /> + control_name="RenderColorGradeHighlights" /> - - - - - + control_name="RenderColorGradeShadows" /> + control_name="RenderColorGradeWhitePoint" /> + + + control_name="RenderColorGradeVibrance" /> + control_name="RenderColorGradeSaturation" /> @@ -1025,8 +1147,8 @@ Row conventions: expanded="false" layout="topleft" height="116" - name="atab_sec_wb" - title="White Balance" + name="atab_sec_basic_adv" + title="Basic - Advanced" fit_panel="true"> + name="sec_basic_adv"> + control_name="RenderColorGradeBrightness" /> + control_name="RenderColorGradeHueShift" /> - + - - + - + + control_name="RenderColorGradeGain" /> - + + + + - + + - + height="72" + grid_divisions="4" + draw_diagonal="false" + name="split_tone_graph" + tool_tip="Which tones each tint reaches. Drag the handle to slide the split point between shadows and highlights -- it is the Balance slider below. Each band is drawn in the colour it applies and fades as its amount drops." + enabled_control="RenderColorGrade"> + + + control_name="RenderSplitToneAmount" /> - - + control_name="RenderSplitToneMidtoneAmount" /> + control_name="RenderSplitToneBalance" /> + name="sec_lut"> - + - - - - - - - - + control_name="RenderColorGradeLUT"> + + - - - - - - - - - - + control_name="RenderColorGradeLUTStrength" /> + + + + + + + + + + + + + + + + + + + + + - - - master/hue/saturation maths + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alcolorwheelmodel.h" + +#include + +namespace tut +{ + struct wheel_data + { + ALColorWheelModel mWheel; + + /// Lift: centred on 0, per-channel -0.5 to +0.5. + void asLift() { mWheel.configure(0.f, -0.5f, 0.5f, false); } + /// Gain / gamma: centred on 1, per-channel 0.5 to 1.5. + void asGain() { mWheel.configure(1.f, 0.5f, 1.5f, false); } + /// A split-tone tint: centred on 0.5 over 0..1, master locked because + /// the renderer normalises tint magnitude away. + void asTint() { mWheel.configure(0.5f, 0.f, 1.f, true); } + + static F32 deg(F32 d) { return d * F_PI / 180.f; } + + /// Smallest absolute difference between two angles, allowing for wrap. + static F32 angleDelta(F32 a, F32 b) + { + F32 d = fmodf(fabsf(a - b), F_TWO_PI); + return (d > F_PI) ? F_TWO_PI - d : d; + } + }; + + typedef test_group wheel_group; + typedef wheel_group::object wheel_object; + tut::wheel_group wg("ALColorWheelModel"); + + // --- the basis itself ---------------------------------------------------- + + // The whole design rests on {(1,1,1)/sqrt3, u, v} being orthonormal -- that + // is what makes the decomposition lossless. A typo in the constants would + // show up here and nowhere else obvious. + template<> template<> + void wheel_object::test<1>() + { + const LLVector3 u = ALColorWheelModel::chromaDirection(0.f); + const LLVector3 v = ALColorWheelModel::chromaDirection(F_PI_BY_TWO); + const LLVector3 grey(1.f, 1.f, 1.f); + + ensure_approximately_equals("u is unit", u.magVec(), 1.f, 5); + ensure_approximately_equals("v is unit", v.magVec(), 1.f, 5); + ensure_approximately_equals("u and v are perpendicular", u * v, 0.f, 5); + ensure_approximately_equals("u has no achromatic part", u * grey, 0.f, 5); + ensure_approximately_equals("v has no achromatic part", v * grey, 0.f, 5); + } + + // Angle lands on the usual colour circle: red at 0, green at 120, blue at 240. + template<> template<> + void wheel_object::test<2>() + { + F32 hue, sat; + ALColorWheelModel::toPolar(LLVector3(1.f, 0.f, 0.f), hue, sat); + ensure_approximately_equals("pure red is hue 0", angleDelta(hue, deg(0.f)), 0.f, 4); + + ALColorWheelModel::toPolar(LLVector3(0.f, 1.f, 0.f), hue, sat); + ensure_approximately_equals("pure green is 120", angleDelta(hue, deg(120.f)), 0.f, 4); + + ALColorWheelModel::toPolar(LLVector3(0.f, 0.f, 1.f), hue, sat); + ensure_approximately_equals("pure blue is 240", angleDelta(hue, deg(240.f)), 0.f, 4); + } + + // A neutral triplet has no chroma, and asking its hue must not produce a + // NaN from atan2(0,0) -- the puck would jump the moment it crossed centre. + template<> template<> + void wheel_object::test<3>() + { + F32 hue, sat; + for (F32 grey : { -0.4f, 0.f, 0.5f, 1.f }) + { + ALColorWheelModel::toPolar(LLVector3(grey, grey, grey), hue, sat); + ensure_approximately_equals("no saturation", sat, 0.f, 5); + ensure("hue is finite", std::isfinite(hue)); + } + } + + // --- round trip ---------------------------------------------------------- + + // The property everything else depends on: decompose and rebuild returns + // the same triplet. If this fails, typing a number and dragging the puck + // disagree about the value. + template<> template<> + void wheel_object::test<4>() + { + const F32 samples[] = { -0.5f, -0.31f, -0.07f, 0.f, 0.12f, 0.29f, 0.5f }; + for (F32 r : samples) + { + for (F32 g : samples) + { + for (F32 b : samples) + { + const LLVector3 in(r, g, b); + F32 hue, sat; + ALColorWheelModel::toPolar(in, hue, sat); + const LLVector3 out = ALColorWheelModel::toRGB( + ALColorWheelModel::masterOf(in), hue, sat); + + ensure_approximately_equals("r round trips", out.mV[VX], in.mV[VX], 5); + ensure_approximately_equals("g round trips", out.mV[VY], in.mV[VY], 5); + ensure_approximately_equals("b round trips", out.mV[VZ], in.mV[VZ], 5); + } + } + } + } + + // Round trip through the stored value, which is the path the widget takes. + template<> template<> + void wheel_object::test<5>() + { + asLift(); + for (S32 i = 0; i < 24; ++i) + { + const F32 hue = deg((F32)i * 15.f); + const F32 sat = mWheel.getMaxSat() * 0.6f; + mWheel.setPolar(hue, sat); + + ensure_approximately_equals("hue survives", angleDelta(mWheel.getHue(), hue), 0.f, 4); + ensure_approximately_equals("saturation survives", mWheel.getSat(), sat, 4); + ensure_approximately_equals("master untouched", mWheel.getMaster(), 0.f, 5); + } + } + + // --- the hexagon --------------------------------------------------------- + + // getMaxSat is the inradius, so a puck on the rim is reachable at EVERY + // hue with the master centred -- nothing gets clamped. + template<> template<> + void wheel_object::test<6>() + { + asLift(); + const F32 rim = mWheel.getMaxSat(); + for (S32 i = 0; i < 72; ++i) + { + const F32 hue = deg((F32)i * 5.f); + mWheel.setPolar(hue, rim); + ensure_approximately_equals("rim is reachable at every hue", mWheel.getSat(), rim, 3); + ensure_approximately_equals("and the hue is the one asked for", + angleDelta(mWheel.getHue(), hue), 0.f, 3); + } + } + + // The inradius is sqrt(1.5) * halfRange, and the tightest directions are + // the primaries, where one channel lands exactly on its limit. + template<> template<> + void wheel_object::test<7>() + { + asLift(); + ensure_approximately_equals("inradius", mWheel.getMaxSat(), sqrtf(1.5f) * 0.5f, 4); + + mWheel.setPolar(0.f, mWheel.getMaxSat()); + ensure_approximately_equals("red sits on its limit", mWheel.getRGB().mV[VX], 0.5f, 3); + + asGain(); + ensure_approximately_equals("gain inradius", mWheel.getMaxSat(), sqrtf(1.5f) * 0.5f, 4); + } + + // Past the rim the channel clamp bites: the value stays legal, and the + // puck reports where the value actually is rather than where the pointer + // went. + // + // The ceiling here is NOT getMaxSat(). Three different radii are in play + // and it is worth being explicit about which is which, for a range of + // width w: + // + // w * 1/sqrt(6) = 0.408w inradius with the master pinned to centre + // -- what getMaxSat() returns, so the rim is + // reachable at every hue + // w * 1/sqrt(8) = 0.354w ... times 2/sqrt(3): the circumradius of that + // same hexagon, at 30, 90, 150 degrees + // w * sqrt(2/3) = 0.816w the widest deviation ANY legal triplet has + // + // Clamping moves the mean, so the result leaves the master-pinned hexagon + // and lands on the projection of the whole cube -- the third figure. A + // bound of the second is too tight and fails here. + template<> template<> + void wheel_object::test<8>() + { + asLift(); + const F32 rim = mWheel.getMaxSat(); + const F32 widest = sqrtf(2.f / 3.f) * (mWheel.getMax() - mWheel.getMin()); + + for (S32 i = 0; i < 36; ++i) + { + const F32 hue = deg((F32)i * 10.f); + mWheel.setPolar(hue, rim * 4.f); + + const LLVector3& rgb = mWheel.getRGB(); + for (S32 c = 0; c < 3; ++c) + { + ensure("channel stayed in range", rgb.mV[c] >= -0.5f - 1e-5f && rgb.mV[c] <= 0.5f + 1e-5f); + } + ensure("saturation did not run away", mWheel.getSat() <= widest + 1e-4f); + ensure("but it did move out to the boundary", mWheel.getSat() > rim * 0.9f); + } + + // Far enough past the rim in a primary direction and the triplet is + // pinned to a cube corner, which is exactly the widest case. + mWheel.setPolar(0.f, rim * 4.f); + ensure_approximately_equals("corner-pinned saturation", mWheel.getSat(), widest, 4); + ensure_approximately_equals("r on its ceiling", mWheel.getRGB().mV[VX], 0.5f, 5); + ensure_approximately_equals("g on its floor", mWheel.getRGB().mV[VY], -0.5f, 5); + } + + // Clamping must not silently rotate the hue -- the puck would slide around + // the ring while the user dragged straight outward. + template<> template<> + void wheel_object::test<9>() + { + asLift(); + for (S32 i = 0; i < 12; ++i) + { + const F32 hue = deg((F32)i * 30.f); + mWheel.setPolar(hue, mWheel.getMaxSat() * 3.f); + ensure_approximately_equals("hue held through the clamp", + angleDelta(mWheel.getHue(), hue), 0.f, 3); + } + } + + // --- master -------------------------------------------------------------- + + // Moving the master keeps the chroma, which is what makes the wheel and + // its slider feel independent. + template<> template<> + void wheel_object::test<10>() + { + asGain(); + mWheel.setPolar(deg(200.f), mWheel.getMaxSat() * 0.4f); + const F32 hue = mWheel.getHue(); + const F32 sat = mWheel.getSat(); + + mWheel.setMaster(1.2f); + ensure_approximately_equals("master moved", mWheel.getMaster(), 1.2f, 4); + ensure_approximately_equals("hue kept", angleDelta(mWheel.getHue(), hue), 0.f, 3); + ensure_approximately_equals("saturation kept", mWheel.getSat(), sat, 3); + } + + // A master pushed past its range clamps, and the triplet stays legal. + template<> template<> + void wheel_object::test<11>() + { + asGain(); + mWheel.setMaster(9.f); + ensure("master clamped", mWheel.getMaster() <= 1.5f + 1e-5f); + for (S32 c = 0; c < 3; ++c) + { + ensure("channel in range", mWheel.getRGB().mV[c] <= 1.5f + 1e-5f); + } + } + + // Tint wheels lock the master, because the renderer divides each tint by + // dot(tint, LUMA) -- magnitude cancels, so a master there would be a + // control that visibly does nothing. + template<> template<> + void wheel_object::test<12>() + { + asTint(); + mWheel.setPolar(deg(90.f), mWheel.getMaxSat() * 0.5f); + const F32 master = mWheel.getMaster(); + + mWheel.setMaster(0.9f); + ensure_approximately_equals("master ignored", mWheel.getMaster(), master, 5); + + // ... and a fresh puck move re-centres on the configured neutral. + mWheel.setPolar(deg(30.f), mWheel.getMaxSat() * 0.5f); + ensure_approximately_equals("re-centred on neutral", mWheel.getMaster(), 0.5f, 4); + } + + // --- ring / puck agreement ---------------------------------------------- + + // The ring is generated from the same basis as the puck, so the colour + // shown at an angle is the colour dragging to that angle produces. Drawing + // a generic HSV ring instead is where these two drift apart. + template<> template<> + void wheel_object::test<13>() + { + for (S32 i = 0; i < 36; ++i) + { + const F32 hue = deg((F32)i * 10.f); + const LLVector3 ring = ALColorWheelModel::ringColor(hue); + + F32 ring_hue, ring_sat; + ALColorWheelModel::toPolar(ring, ring_hue, ring_sat); + ensure_approximately_equals("ring hue matches the puck's", + angleDelta(ring_hue, hue), 0.f, 3); + ensure("ring is actually coloured", ring_sat > 0.f); + } + } + + // The ring's reference chroma is chosen to stay inside 0..1, so no channel + // is ever clipped -- clipping would bend the hue it is advertising. + template<> template<> + void wheel_object::test<14>() + { + for (S32 i = 0; i < 72; ++i) + { + const LLVector3 ring = ALColorWheelModel::ringColor(deg((F32)i * 5.f)); + for (S32 c = 0; c < 3; ++c) + { + ensure("ring channel is strictly inside the gamut", + ring.mV[c] > 0.001f && ring.mV[c] < 0.999f); + } + } + } + + // --- numeric entry and reset -------------------------------------------- + + // Typing into one field leaves the other two alone and moves the puck. + template<> template<> + void wheel_object::test<15>() + { + asLift(); + mWheel.setChannel(0, 0.2f); + ensure_approximately_equals("channel written", mWheel.getRGB().mV[VX], 0.2f, 5); + ensure_approximately_equals("g untouched", mWheel.getRGB().mV[VY], 0.f, 5); + ensure_approximately_equals("b untouched", mWheel.getRGB().mV[VZ], 0.f, 5); + ensure("puck moved off centre", mWheel.getSat() > 0.f); + ensure_approximately_equals("towards red", angleDelta(mWheel.getHue(), 0.f), 0.f, 3); + + mWheel.setChannel(0, 9.f); + ensure_approximately_equals("typed value clamped", mWheel.getRGB().mV[VX], 0.5f, 5); + + mWheel.setChannel(7, 1.f); // out of range, ignored + ensure_approximately_equals("bad index ignored", mWheel.getRGB().mV[VX], 0.5f, 5); + } + + // Reset returns the configured neutral for each flavour. + template<> template<> + void wheel_object::test<16>() + { + asLift(); + mWheel.setPolar(deg(45.f), mWheel.getMaxSat()); + mWheel.reset(); + ensure_approximately_equals("lift neutral is 0", mWheel.getMaster(), 0.f, 5); + ensure_approximately_equals("and colourless", mWheel.getSat(), 0.f, 5); + + asGain(); + mWheel.reset(); + ensure_approximately_equals("gain neutral is 1", mWheel.getMaster(), 1.f, 5); + + asTint(); + mWheel.reset(); + ensure_approximately_equals("tint neutral is 0.5", mWheel.getMaster(), 0.5f, 5); + ensure_approximately_equals("tint neutral r", mWheel.getRGB().mV[VX], 0.5f, 5); + } + + // Re-configuring re-clamps whatever was already held, so switching a wheel + // from lift to gain cannot leave an out-of-range value behind. + template<> template<> + void wheel_object::test<17>() + { + asLift(); + mWheel.setRGB(LLVector3(-0.4f, 0.f, 0.3f)); + asGain(); + for (S32 c = 0; c < 3; ++c) + { + ensure("re-clamped on configure", + mWheel.getRGB().mV[c] >= 0.5f - 1e-5f && mWheel.getRGB().mV[c] <= 1.5f + 1e-5f); + } + } + + // A reversed range is accepted rather than producing a negative width. + template<> template<> + void wheel_object::test<18>() + { + mWheel.configure(0.f, 0.5f, -0.5f, false); + ensure_approximately_equals("min is the low one", mWheel.getMin(), -0.5f, 5); + ensure_approximately_equals("max is the high one", mWheel.getMax(), 0.5f, 5); + ensure("max saturation is positive", mWheel.getMaxSat() > 0.f); + } +} diff --git a/indra/newview/tests/alcurvemodel_test.cpp b/indra/newview/tests/alcurvemodel_test.cpp new file mode 100644 index 0000000000..5a12549edc --- /dev/null +++ b/indra/newview/tests/alcurvemodel_test.cpp @@ -0,0 +1,484 @@ +/** + * @file alcurvemodel_test.cpp + * @brief Unit tests for the curve editor's shape model + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alcurvemodel.h" + +#include +#include + +namespace tut +{ + struct curve_data + { + ALCurveModel mCurve; + + /// cg_sCurve transcribed straight from + /// class1/alchemy/colorGradeUtilF.glsl, with the uCurveInvRange that + /// pipeline.cpp uploads folded in. Written out longhand on purpose: + /// the point of the comparison is that it is an independent + /// transcription of the shader, not a call back into the model. + static F32 shaderCurve(F32 x, F32 toe, F32 shoulder, F32 strength) + { + F32 inv_range = 1.0f / std::max(shoulder - toe, 1e-4f); + F32 t = (x - toe) * inv_range; + t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + F32 s = t * t * (3.0f - 2.0f * t); + F32 k = strength < 0.0f ? 0.0f : (strength > 1.0f ? 1.0f : strength); + return x * (1.0f - k) + s * k; // mix(x, s, k) + } + + /// applySplitToning's three luma masks, transcribed the same way and + /// for the same reason: an independent copy of the shader, so a change + /// to either side shows up as a disagreement rather than as two + /// matching edits. + static void shaderSplitWeights(F32 l, F32 mid, F32& lo, F32& md, F32& hi) + { + auto ss = [](F32 e0, F32 e1, F32 x) { + F32 t = (x - e0) / (e1 - e0); + t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + return t * t * (3.0f - 2.0f * t); + }; + hi = ss(mid, mid + 0.35f, l); + lo = 1.0f - ss(mid - 0.35f, mid, l); + md = std::max(1.0f - hi - lo, 0.0f); + } + + static std::vector pts(std::initializer_list> in) + { + std::vector out; + for (const auto& p : in) + { + out.push_back(ALCurveModel::Point{ p.first, p.second }); + } + return out; + } + }; + + typedef test_group curve_group; + typedef curve_group::object curve_object; + tut::curve_group cg("ALCurveModel"); + + // --- smoothstep: agreement with the shader ------------------------------- + + // Zero strength is the identity, whatever the toe and shoulder say. + template<> template<> + void curve_object::test<1>() + { + mCurve.setSmoothstep(0.2f, 0.8f, 0.f); + for (S32 i = 0; i <= 10; ++i) + { + const F32 x = i * 0.1f; + ensure_approximately_equals("identity at strength 0", mCurve.evaluate(x), x, 6); + } + } + + // Full strength is a pure smoothstep between toe and shoulder. + template<> template<> + void curve_object::test<2>() + { + mCurve.setSmoothstep(0.25f, 0.75f, 1.f); + ensure_approximately_equals("flat below the toe", mCurve.evaluate(0.1f), 0.f, 6); + ensure_approximately_equals("flat above the shoulder", mCurve.evaluate(0.9f), 1.f, 6); + ensure_approximately_equals("midpoint", mCurve.evaluate(0.5f), 0.5f, 6); + } + + // The model and an independent transcription of the shader agree across a + // sweep of parameters, including the degenerate shoulder <= toe. + template<> template<> + void curve_object::test<3>() + { + const F32 toes[] = { 0.f, 0.1f, 0.35f, 0.6f }; + const F32 shoulders[] = { 0.05f, 0.4f, 0.85f, 1.f }; + const F32 strengths[] = { 0.f, 0.25f, 0.7f, 1.f }; + + for (F32 toe : toes) + { + for (F32 shoulder : shoulders) + { + for (F32 strength : strengths) + { + for (S32 i = 0; i <= 20; ++i) + { + const F32 x = i * 0.05f; + ensure_approximately_equals( + "model matches the shader", + ALCurveModel::smoothstep(x, toe, shoulder, strength), + shaderCurve(x, toe, shoulder, strength), 6); + } + } + } + } + } + + // A shoulder at or below the toe must not divide by zero; it degenerates + // to a step at the toe, which is what the shader's guarded reciprocal does. + template<> template<> + void curve_object::test<4>() + { + const F32 y_below = ALCurveModel::smoothstep(0.3f, 0.5f, 0.5f, 1.f); + const F32 y_above = ALCurveModel::smoothstep(0.7f, 0.5f, 0.5f, 1.f); + ensure("finite below", std::isfinite(y_below)); + ensure("finite above", std::isfinite(y_above)); + ensure_approximately_equals("black below the toe", y_below, 0.f, 5); + ensure_approximately_equals("white above the toe", y_above, 1.f, 5); + } + + // Strength outside 0..1 is clamped, matching pipeline.cpp's llclamp on the + // uniform it uploads. + template<> template<> + void curve_object::test<5>() + { + ensure_approximately_equals("over-strength clamps to 1", + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, 4.f), + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, 1.f), 6); + ensure_approximately_equals("negative strength clamps to 0", + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, -2.f), + 0.3f, 6); + } + + // --- spline: ordering and point management ------------------------------- + + // A fresh model is the identity ramp under either kind. + template<> template<> + void curve_object::test<6>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + ensure_equals("two points by default", mCurve.getPointCount(), 2); + for (S32 i = 0; i <= 10; ++i) + { + const F32 x = i * 0.1f; + ensure_approximately_equals("identity ramp", mCurve.evaluate(x), x, 5); + } + } + + // Points added out of order come back sorted by x, and the reported index + // is where the point actually landed. + template<> template<> + void curve_object::test<7>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + const S32 idx_hi = mCurve.addPoint(0.8f, 0.9f); + const S32 idx_lo = mCurve.addPoint(0.2f, 0.1f); + + ensure_equals("high point went before the last", idx_hi, 1); + ensure_equals("low point went before the high one", idx_lo, 1); + ensure_equals("four points", mCurve.getPointCount(), 4); + + const auto& p = mCurve.getPoints(); + for (size_t i = 1; i < p.size(); ++i) + { + ensure("x is ascending", p[i].mX > p[i - 1].mX); + } + ensure_approximately_equals("second point is the low one", p[1].mX, 0.2f, 5); + ensure_approximately_equals("third point is the high one", p[2].mX, 0.8f, 5); + } + + // Coincident points are pushed apart rather than sharing an x, so no + // segment can have zero width. + template<> template<> + void curve_object::test<8>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.3f }, { 0.5f, 0.7f }, { 1.f, 1.f } })); + + const auto& p = mCurve.getPoints(); + ensure_equals("kept all four", (S32)p.size(), 4); + for (size_t i = 1; i < p.size(); ++i) + { + ensure("gap is at least MIN_POINT_GAP", + p[i].mX - p[i - 1].mX >= ALCurveModel::MIN_POINT_GAP - 1e-6f); + } + ensure("evaluation is finite", std::isfinite(mCurve.evaluate(0.5f))); + } + + // A drag cannot push a point past its neighbours, and locked endpoints + // keep their x while still accepting a new height. + template<> template<> + void curve_object::test<9>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.3f, 0.3f }, { 0.6f, 0.6f }, { 1.f, 1.f } })); + + mCurve.movePoint(1, 0.95f, 0.5f); + const auto& p = mCurve.getPoints(); + ensure("clamped below its right neighbour", p[1].mX < p[2].mX); + ensure_approximately_equals("clamped to exactly the gap", + p[1].mX, 0.6f - ALCurveModel::MIN_POINT_GAP, 5); + ensure("still ordered", p[0].mX < p[1].mX && p[2].mX < p[3].mX); + + mCurve.movePoint(0, 0.4f, 0.25f); + ensure_approximately_equals("first point x stays pinned", mCurve.getPoints()[0].mX, 0.f, 6); + ensure_approximately_equals("first point y moved", mCurve.getPoints()[0].mY, 0.25f, 5); + + mCurve.movePoint(3, 0.4f, 0.8f); + ensure_approximately_equals("last point x stays pinned", mCurve.getPoints()[3].mX, 1.f, 6); + } + + // Removal refuses to break the curve: never below two points, and never an + // endpoint while the endpoints are locked. + template<> template<> + void curve_object::test<10>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.4f }, { 1.f, 1.f } })); + + ensure("cannot remove the first", !mCurve.removePoint(0)); + ensure("cannot remove the last", !mCurve.removePoint(2)); + ensure("out of range refused", !mCurve.removePoint(7)); + ensure("interior removed", mCurve.removePoint(1)); + ensure_equals("two left", mCurve.getPointCount(), 2); + ensure("cannot go below two", !mCurve.removePoint(0)); + } + + // --- spline: shape ------------------------------------------------------- + + // The curve passes through every control point. + template<> template<> + void curve_object::test<11>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.05f }, { 0.25f, 0.5f }, { 0.7f, 0.6f }, { 1.f, 0.95f } })); + + for (const auto& p : mCurve.getPoints()) + { + ensure_approximately_equals("interpolates its points", + mCurve.evaluate(p.mX), p.mY, 4); + } + } + + // Monotone data yields a monotone curve. A natural or Catmull-Rom spline + // fails this on exactly this shape -- a long flat run into a sharp rise + // makes it dip below the flat before climbing. + template<> template<> + void curve_object::test<12>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.4f, 0.02f }, { 0.6f, 0.05f }, { 0.7f, 0.9f }, { 1.f, 1.f } })); + + std::vector s; + mCurve.sample(s, 257); + for (size_t i = 1; i < s.size(); ++i) + { + ensure("never decreases", s[i] >= s[i - 1] - 1e-5f); + } + } + + // A flat run stays exactly flat -- no ringing between equal-valued points. + template<> template<> + void curve_object::test<13>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.3f, 0.5f }, { 0.7f, 0.5f }, { 1.f, 1.f } })); + + for (S32 i = 0; i <= 8; ++i) + { + const F32 x = 0.3f + i * 0.05f; + ensure_approximately_equals("flat between equal points", mCurve.evaluate(x), 0.5f, 4); + } + } + + // Outside the point range the curve holds its end values rather than + // extrapolating off the graph. + template<> template<> + void curve_object::test<14>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setEndpointsLocked(false); + mCurve.setPoints(pts({ { 0.2f, 0.3f }, { 0.8f, 0.7f } })); + + ensure_approximately_equals("holds the left value", mCurve.evaluate(0.f), 0.3f, 5); + ensure_approximately_equals("holds the right value", mCurve.evaluate(1.f), 0.7f, 5); + } + + // Output never leaves 0..1, so a curve can never ask the caller to write an + // out-of-gamut value. + template<> template<> + void curve_object::test<15>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.05f, 0.99f }, { 0.1f, 0.01f }, { 1.f, 1.f } })); + + std::vector s; + mCurve.sample(s, 129); + for (F32 y : s) + { + ensure("in range", y >= 0.f && y <= 1.f); + ensure("finite", std::isfinite(y)); + } + } + + // --- sampling ------------------------------------------------------------ + + // sample() spans 0..1 inclusive and honours its count. + template<> template<> + void curve_object::test<16>() + { + mCurve.setSmoothstep(0.1f, 0.9f, 1.f); + + std::vector s; + mCurve.sample(s, 65); + ensure_equals("count honoured", (S32)s.size(), 65); + ensure_approximately_equals("starts at evaluate(0)", s.front(), mCurve.evaluate(0.f), 6); + ensure_approximately_equals("ends at evaluate(1)", s.back(), mCurve.evaluate(1.f), 6); + + mCurve.sample(s, 1); + ensure("a single sample is not a curve", s.empty()); + mCurve.sample(s, 0); + ensure("zero samples", s.empty()); + } + + // Unlocking the endpoints lets the first and last points move horizontally; + // re-locking snaps them back to the full domain. + template<> template<> + void curve_object::test<17>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setEndpointsLocked(false); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.5f }, { 1.f, 1.f } })); + + mCurve.movePoint(0, 0.2f, 0.1f); + ensure_approximately_equals("unlocked end moved", mCurve.getPoints()[0].mX, 0.2f, 5); + + mCurve.setEndpointsLocked(true); + ensure_approximately_equals("relock snaps to 0", mCurve.getPoints()[0].mX, 0.f, 6); + ensure_approximately_equals("relock snaps to 1", mCurve.getPoints().back().mX, 1.f, 6); + } + + // setPoints refuses to leave the model unusable. + template<> template<> + void curve_object::test<18>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.3f, 0.3f } })); + ensure_equals("degenerate input resets to a ramp", mCurve.getPointCount(), 2); + ensure_approximately_equals("and it is the identity", mCurve.evaluate(0.5f), 0.5f, 5); + } + + // Switching kinds does not disturb the other kind's state, so a widget can + // offer both without a round trip losing anything. + template<> template<> + void curve_object::test<19>() + { + mCurve.setSmoothstep(0.2f, 0.7f, 0.6f); + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.8f }, { 1.f, 1.f } })); + + mCurve.setKind(ALCurveModel::KIND_SMOOTHSTEP); + ensure_approximately_equals("toe survived", mCurve.getToe(), 0.2f, 6); + ensure_approximately_equals("shoulder survived", mCurve.getShoulder(), 0.7f, 6); + ensure_approximately_equals("strength survived", mCurve.getStrength(), 0.6f, 6); + + mCurve.setKind(ALCurveModel::KIND_SPLINE); + ensure_equals("points survived", mCurve.getPointCount(), 3); + ensure_approximately_equals("and their values did", + mCurve.evaluate(0.5f), 0.8f, 4); + } + + // --- split-tone bands ---------------------------------------------------- + + // Agreement with the shader, across the whole range of split points the + // Balance slider can ask for. + template<> template<> + void curve_object::test<20>() + { + for (F32 mid : { 0.1f, 0.3f, 0.5f, 0.7f, 0.9f }) + { + for (S32 i = 0; i <= 64; ++i) + { + const F32 l = (F32)i / 64.f; + F32 lo, md, hi; + shaderSplitWeights(l, mid, lo, md, hi); + const auto w = ALCurveModel::splitToneWeights(l, mid); + ensure_approximately_equals("shadow matches the shader", w.mShadow, lo, 5); + ensure_approximately_equals("midtone matches the shader", w.mMidtone, md, 5); + ensure_approximately_equals("highlight matches the shader", w.mHighlight, hi, 5); + } + } + } + + // The three weights partition the luma range: they sum to one everywhere. + // That is what makes the band graph honest -- a tone is never partly + // untinted, only ever shared between neighbouring bands -- and it holds + // because the two ramps meet at the split without overlapping, so the + // midtone remainder is never clamped away. + template<> template<> + void curve_object::test<21>() + { + for (F32 mid : { 0.1f, 0.35f, 0.5f, 0.65f, 0.9f }) + { + for (S32 i = 0; i <= 64; ++i) + { + const auto w = ALCurveModel::splitToneWeights((F32)i / 64.f, mid); + ensure_approximately_equals("weights sum to one", + w.mShadow + w.mMidtone + w.mHighlight, 1.f, 5); + ensure("shadow in range", w.mShadow >= 0.f && w.mShadow <= 1.f); + ensure("midtone in range", w.mMidtone >= 0.f && w.mMidtone <= 1.f); + ensure("highlight in range", w.mHighlight >= 0.f && w.mHighlight <= 1.f); + } + } + + const F32 mid = 0.55f; + const auto at_mid = ALCurveModel::splitToneWeights(mid, mid); + ensure_approximately_equals("midtone peaks at the split", at_mid.mMidtone, 1.f, 5); + ensure_approximately_equals("shadow is spent there", at_mid.mShadow, 0.f, 5); + ensure_approximately_equals("highlight has not started", at_mid.mHighlight, 0.f, 5); + + const auto black = ALCurveModel::splitToneWeights(0.f, mid); + const auto white = ALCurveModel::splitToneWeights(1.f, mid); + ensure_approximately_equals("black is all shadow", black.mShadow, 1.f, 5); + ensure_approximately_equals("white is all highlight", white.mHighlight, 1.f, 5); + + // Monotone, so the bands cannot cross back over themselves. If the + // shader's smoothstep edges were ever swapped, this is what notices. + F32 prev_hi = -1.f, prev_lo = 2.f; + for (S32 i = 0; i <= 64; ++i) + { + const auto w = ALCurveModel::splitToneWeights((F32)i / 64.f, mid); + ensure("highlight never falls", w.mHighlight >= prev_hi - 1e-5f); + ensure("shadow never rises", w.mShadow <= prev_lo + 1e-5f); + prev_hi = w.mHighlight; + prev_lo = w.mShadow; + } + } + + // Balance and split point invert each other across the whole slider range, + // which is what lets the graph's handle be read back into the setting + // without the value creeping a little on every drag. + template<> template<> + void curve_object::test<22>() + { + for (S32 i = -10; i <= 10; ++i) + { + const F32 balance = (F32)i / 10.f; + const F32 mid = ALCurveModel::splitToneMid(balance); + ensure("split point stays in range", mid >= 0.1f - 1e-5f && mid <= 0.9f + 1e-5f); + ensure_approximately_equals("balance round-trips", + ALCurveModel::splitToneBalance(mid), balance, 5); + } + + ensure_approximately_equals("neutral balance splits at mid grey", + ALCurveModel::splitToneMid(0.f), 0.5f, 6); + // Clamped, not wrapped: pipeline.cpp clamps the balance before it + // uploads the split point, so the graph must agree rather than plot a + // split the renderer will never use. + ensure_approximately_equals("out-of-range balance clamps", + ALCurveModel::splitToneMid(3.f), 0.9f, 6); + ensure_approximately_equals("and so does the inverse", + ALCurveModel::splitToneBalance(2.f), 1.f, 6); + } +} diff --git a/indra/newview/tests/algradehistory_test.cpp b/indra/newview/tests/algradehistory_test.cpp new file mode 100644 index 0000000000..6ce9b01ed9 --- /dev/null +++ b/indra/newview/tests/algradehistory_test.cpp @@ -0,0 +1,251 @@ +/** + * @file algradehistory_test.cpp + * @brief Unit tests for the Lightbox undo stack + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + * The behaviour that matters is what counts as "one thing the user did": a + * whole drag, a whole section reset. Get that wrong and Ctrl+Z looks broken + * whichever way it errs. + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../algradehistory.h" + +namespace tut +{ + struct history_data + { + ALGradeHistory mHistory; + + /// Simulate a drag: `steps` commits to one control, `dt` apart. + void drag(const std::string& name, S32 steps, F32 dt, F32 start_time = 1.f) + { + for (S32 i = 0; i < steps; ++i) + { + mHistory.record(name, LLSD((F32)i), LLSD((F32)(i + 1)), start_time + dt * (F32)i); + } + } + }; + + typedef test_group history_group; + typedef history_group::object history_object; + tut::history_group hg("ALGradeHistory"); + + // The basic contract: one change, one step, and undo hands back the value + // it started from. + template<> template<> + void history_object::test<1>() + { + ensure("nothing to undo yet", !mHistory.canUndo()); + ensure("nothing to redo yet", !mHistory.canRedo()); + + mHistory.record("RenderColorGradeSaturation", LLSD(1.0), LLSD(1.5), 1.f); + ensure("something to undo", mHistory.canUndo()); + ensure_equals("one step", mHistory.depth(), (size_t)1); + + const auto* t = mHistory.undo(); + ensure("undo returned a transaction", t != nullptr); + ensure_equals("covering one control", t->size(), (size_t)1); + ensure_equals("the right control", t->front().mName, std::string("RenderColorGradeSaturation")); + ensure_equals("restoring the original", t->front().mBefore.asReal(), 1.0); + ensure("nothing left to undo", !mHistory.canUndo()); + ensure("but something to redo", mHistory.canRedo()); + } + + // The one everybody notices. A wheel drag emits a commit per mouse-move; + // without coalescing one drag is a hundred undo steps and Ctrl+Z looks + // broken. The whole drag must collapse to a single step -- and that step + // must start from where the drag started, not from its penultimate value. + template<> template<> + void history_object::test<2>() + { + drag("RenderColorGradeLift", 100, 0.01f); + + ensure_equals("a whole drag is one step", mHistory.depth(), (size_t)1); + + const auto* t = mHistory.undo(); + ensure("got the step", t != nullptr); + ensure_equals("from where the drag began", t->front().mBefore.asReal(), 0.0); + ensure_equals("to where it ended", t->front().mAfter.asReal(), 100.0); + } + + // Pausing ends the gesture: two deliberate edits are two steps, however + // much the same control they touch. + template<> template<> + void history_object::test<3>() + { + mHistory.record("RenderColorGradeGain", LLSD(1.0), LLSD(1.1), 1.f); + mHistory.record("RenderColorGradeGain", LLSD(1.1), LLSD(1.2), + 1.f + ALGradeHistory::COALESCE_SECONDS * 2.f); + ensure_equals("two separate edits", mHistory.depth(), (size_t)2); + } + + // A different control is always a different action, however fast the user + // moved between them -- otherwise nudging saturation would swallow the + // contrast change before it. + template<> template<> + void history_object::test<4>() + { + mHistory.record("RenderColorGradeContrast", LLSD(1.0), LLSD(1.2), 1.f); + mHistory.record("RenderColorGradeSaturation", LLSD(1.0), LLSD(1.2), 1.01f); + ensure_equals("two steps", mHistory.depth(), (size_t)2); + } + + // A section's Reset All writes many controls; that is one thing the user + // did, so it must undo in one go. + template<> template<> + void history_object::test<5>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(1.0), LLSD(0.0), 1.f); + mHistory.record("B", LLSD(2.0), LLSD(0.0), 1.f); + mHistory.record("C", LLSD(3.0), LLSD(0.0), 1.f); + mHistory.endGroup(); + + ensure_equals("one step", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("covering all three", t->size(), (size_t)3); + } + + // Within a group a control may be written more than once. The group should + // still describe one before and one after for it, or undo would restore an + // intermediate value. + template<> template<> + void history_object::test<6>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(1.0), LLSD(2.0), 1.f); + mHistory.record("A", LLSD(2.0), LLSD(3.0), 1.f); + mHistory.endGroup(); + + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("still one control", t->size(), (size_t)1); + ensure_equals("from the original value", t->front().mBefore.asReal(), 1.0); + ensure_equals("to the final one", t->front().mAfter.asReal(), 3.0); + } + + // An edit made after undoing discards the redo tail: that future is no + // longer reachable. + template<> template<> + void history_object::test<7>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.record("B", LLSD(0.0), LLSD(1.0), 2.f); + mHistory.undo(); + ensure("can redo before the new edit", mHistory.canRedo()); + + mHistory.record("C", LLSD(0.0), LLSD(1.0), 3.f); + ensure("redo tail is gone", !mHistory.canRedo()); + ensure_equals("and the stack was truncated", mHistory.depth(), (size_t)2); + } + + // Redo walks forward again and hands back the destination values. + template<> template<> + void history_object::test<8>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.undo(); + + const auto* t = mHistory.redo(); + ensure("redo returned the step", t != nullptr); + ensure_equals("with the destination", t->front().mAfter.asReal(), 1.0); + ensure("nothing further to redo", !mHistory.canRedo()); + ensure("and it is undoable again", mHistory.canUndo()); + } + + // Applying an undo writes settings, which is what feeds this class. If that + // fed straight back in it would either loop or corrupt the step being + // undone, so a write immediately after an undo must start a new step rather + // than coalesce into the old one. + template<> template<> + void history_object::test<9>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.undo(); + mHistory.record("A", LLSD(1.0), LLSD(2.0), 1.01f); + + ensure_equals("the undone step was replaced, not extended", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got the new step", t != nullptr); + ensure_equals("carrying the new values", t->front().mBefore.asReal(), 1.0); + } + + // A long session must not grow without bound, and it is the oldest history + // that stops being interesting. + template<> template<> + void history_object::test<10>() + { + for (size_t i = 0; i < ALGradeHistory::MAX_DEPTH + 20; ++i) + { + mHistory.record("A", LLSD((F64)i), LLSD((F64)(i + 1)), (F32)i * 10.f); + } + ensure_equals("capped", mHistory.depth(), ALGradeHistory::MAX_DEPTH); + + // The newest is still the newest after the drop. + const auto* t = mHistory.undo(); + ensure("got the newest", t != nullptr); + ensure_equals("which is the last one recorded", + t->front().mAfter.asReal(), (F64)(ALGradeHistory::MAX_DEPTH + 20)); + } + + // An empty group leaves nothing behind -- a Reset All on a section that was + // already at defaults should not put a do-nothing step on the stack. + template<> template<> + void history_object::test<11>() + { + mHistory.beginGroup(); + mHistory.endGroup(); + ensure("no step", !mHistory.canUndo()); + ensure_equals("nothing recorded", mHistory.depth(), (size_t)0); + } + + // Nested groups are still one step: applying a Look may reset sections on + // the way through, and the user did one thing. + template<> template<> + void history_object::test<12>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.beginGroup(); + mHistory.record("B", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.endGroup(); + mHistory.record("C", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.endGroup(); + + ensure_equals("one step", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("covering all three", t->size(), (size_t)3); + } + + // Undoing past the beginning, or redoing past the end, answers null rather + // than misbehaving -- the key handler calls these without checking first. + template<> template<> + void history_object::test<13>() + { + ensure("undo on empty is null", mHistory.undo() == nullptr); + ensure("redo on empty is null", mHistory.redo() == nullptr); + + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + ensure("undo works once", mHistory.undo() != nullptr); + ensure("and then stops", mHistory.undo() == nullptr); + ensure("redo works once", mHistory.redo() != nullptr); + ensure("and then stops", mHistory.redo() == nullptr); + + mHistory.clear(); + ensure("clear empties it", !mHistory.canUndo() && !mHistory.canRedo()); + ensure_equals("really empty", mHistory.depth(), (size_t)0); + } +} diff --git a/indra/newview/tests/alscopedata_test.cpp b/indra/newview/tests/alscopedata_test.cpp new file mode 100644 index 0000000000..02cfc4f4ba --- /dev/null +++ b/indra/newview/tests/alscopedata_test.cpp @@ -0,0 +1,647 @@ +/** + * @file alscopedata_test.cpp + * @brief Unit tests for the scopes floater's histogram data + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alscopedata.h" + +#include + +namespace tut +{ + struct scope_data + { + ALScopeData mScope; + + /// A buffer of `count` identical RGBA pixels. + static std::vector flat(U8 r, U8 g, U8 b, S32 count) + { + std::vector px; + px.reserve(count * 4); + for (S32 i = 0; i < count; ++i) + { + px.push_back(r); + px.push_back(g); + px.push_back(b); + px.push_back(255); + } + return px; + } + + /// A ramp: pixel i is grey level i, so every bin gets exactly one hit. + static std::vector ramp() + { + std::vector px; + px.reserve(ALScopeData::BIN_COUNT * 4); + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + const U8 v = (U8)i; + px.push_back(v); + px.push_back(v); + px.push_back(v); + px.push_back(255); + } + return px; + } + + static F32 chromaSum(const ALScopeData& d) + { + F32 total = 0.f; + for (S32 u = 0; u < ALScopeData::CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < ALScopeData::CHROMA_SIZE; ++v) + { + total += d.getChromaCell(u, v); + } + } + return total; + } + + /// The one cell holding everything, for a sample of identical pixels. + static void soleCell(const ALScopeData& d, S32& u_out, S32& v_out) + { + u_out = -1; + v_out = -1; + for (S32 u = 0; u < ALScopeData::CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < ALScopeData::CHROMA_SIZE; ++v) + { + if (d.getChromaCell(u, v) > 0.f) + { + u_out = u; + v_out = v; + return; + } + } + } + } + + static F32 binSum(const ALScopeData& d, ALScopeData::EChannel ch) + { + F32 total = 0.f; + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + total += d.getBin(ch, i); + } + return total; + } + }; + + typedef test_group scope_group; + typedef scope_group::object scope_object; + tut::scope_group sg("ALScopeData"); + + // A fresh object measures nothing and reports nothing. + template<> template<> + void scope_object::test<1>() + { + ensure("starts empty", mScope.isEmpty()); + ensure_equals("no samples", mScope.getSampleCount(), 0); + ensure_approximately_equals("no peak", mScope.getPeak(ALScopeData::CH_LUMA), 0.f, 6); + ensure_approximately_equals("no bins", binSum(mScope, ALScopeData::CH_RED), 0.f, 6); + } + + // A flat image puts everything in one bin per channel. + template<> template<> + void scope_object::test<2>() + { + const std::vector px = flat(64, 128, 192, 100); + mScope.accumulate(px.data(), 100, 1); + + ensure_equals("sample count", mScope.getSampleCount(), 100); + ensure_approximately_equals("all red in bin 64", mScope.getBin(ALScopeData::CH_RED, 64), 1.f, 5); + ensure_approximately_equals("all green in bin 128", mScope.getBin(ALScopeData::CH_GREEN, 128), 1.f, 5); + ensure_approximately_equals("all blue in bin 192", mScope.getBin(ALScopeData::CH_BLUE, 192), 1.f, 5); + ensure_approximately_equals("nothing anywhere else", mScope.getBin(ALScopeData::CH_RED, 65), 0.f, 6); + } + + // Bins are shares of the sample, so they sum to one whatever the size. + template<> template<> + void scope_object::test<3>() + { + const std::vector px = ramp(); + mScope.accumulate(px.data(), ALScopeData::BIN_COUNT, 1); + + for (S32 c = 0; c < ALScopeData::CH_COUNT; ++c) + { + ensure_approximately_equals("bins sum to 1", + binSum(mScope, (ALScopeData::EChannel)c), 1.f, 4); + } + ensure_approximately_equals("a flat ramp peaks at 1/256", + mScope.getPeak(ALScopeData::CH_RED), + 1.f / (F32)ALScopeData::BIN_COUNT, 5); + } + + // Two samples of different sizes but the same content compare equal, + // which is the property that lets the display ignore the sample size. + template<> template<> + void scope_object::test<4>() + { + ALScopeData small_sample; + ALScopeData large_sample; + const std::vector a = flat(200, 100, 50, 9); + const std::vector b = flat(200, 100, 50, 9000); + small_sample.accumulate(a.data(), 9, 1); + large_sample.accumulate(b.data(), 9000, 1); + + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + ensure_approximately_equals("size independent", + small_sample.getBin(ALScopeData::CH_RED, i), + large_sample.getBin(ALScopeData::CH_RED, i), 5); + } + } + + // Luma uses Rec.709 on the encoded values. Pure green must land far above + // pure blue; equal grey must land on itself. + template<> template<> + void scope_object::test<5>() + { + ALScopeData green; + ALScopeData blue; + ALScopeData grey; + const std::vector g = flat(0, 255, 0, 16); + const std::vector b = flat(0, 0, 255, 16); + const std::vector n = flat(137, 137, 137, 16); + green.accumulate(g.data(), 16, 1); + blue.accumulate(b.data(), 16, 1); + grey.accumulate(n.data(), 16, 1); + + // 0.7152 * 255 = 182.4; 0.0722 * 255 = 18.4 + ensure_approximately_equals("green luma", green.getBin(ALScopeData::CH_LUMA, 182), 1.f, 5); + ensure_approximately_equals("blue luma", blue.getBin(ALScopeData::CH_LUMA, 18), 1.f, 5); + ensure_approximately_equals("grey is its own luma", grey.getBin(ALScopeData::CH_LUMA, 137), 1.f, 5); + } + + // The weights sum to unity in fixed point, so white cannot overflow the + // last bin and black cannot underflow the first. + template<> template<> + void scope_object::test<6>() + { + ALScopeData white; + ALScopeData black; + const std::vector w = flat(255, 255, 255, 4); + const std::vector k = flat(0, 0, 0, 4); + white.accumulate(w.data(), 4, 1); + black.accumulate(k.data(), 4, 1); + + ensure_approximately_equals("white luma is 255", + white.getBin(ALScopeData::CH_LUMA, ALScopeData::BIN_COUNT - 1), 1.f, 5); + ensure_approximately_equals("black luma is 0", + black.getBin(ALScopeData::CH_LUMA, 0), 1.f, 5); + } + + // Clipping readouts are the extreme bins, reported as a share of sample. + template<> template<> + void scope_object::test<7>() + { + std::vector px; + // 10 blown, 30 crushed, 60 mid. + for (S32 i = 0; i < 10; ++i) { px.insert(px.end(), { 255, 255, 255, 255 }); } + for (S32 i = 0; i < 30; ++i) { px.insert(px.end(), { 0, 0, 0, 255 }); } + for (S32 i = 0; i < 60; ++i) { px.insert(px.end(), { 128, 128, 128, 255 }); } + mScope.accumulate(px.data(), 100, 1); + + ensure_approximately_equals("10% blown", + mScope.getClippedHigh(ALScopeData::CH_RED), 0.10f, 4); + ensure_approximately_equals("30% crushed", + mScope.getClippedLow(ALScopeData::CH_RED), 0.30f, 4); + ensure_approximately_equals("luma agrees on the blown share", + mScope.getClippedHigh(ALScopeData::CH_LUMA), 0.10f, 4); + } + + // A single blown pixel in a large sample still registers. This is the + // case a box-filtered downsample would have averaged away, and the reason + // the capture point-samples. + template<> template<> + void scope_object::test<8>() + { + std::vector px = flat(100, 100, 100, 10000); + px[0] = px[1] = px[2] = 255; + mScope.accumulate(px.data(), 10000, 1); + + ensure("one blown pixel is visible", mScope.getClippedHigh(ALScopeData::CH_RED) > 0.f); + ensure_approximately_equals("and it is 1 in 10000", + mScope.getClippedHigh(ALScopeData::CH_RED), 0.0001f, 6); + } + + // Accumulating replaces rather than adding to the previous measurement. + template<> template<> + void scope_object::test<9>() + { + const std::vector a = flat(10, 10, 10, 50); + const std::vector b = flat(200, 200, 200, 50); + mScope.accumulate(a.data(), 50, 1); + mScope.accumulate(b.data(), 50, 1); + + ensure_approximately_equals("old bin cleared", mScope.getBin(ALScopeData::CH_RED, 10), 0.f, 6); + ensure_approximately_equals("new bin full", mScope.getBin(ALScopeData::CH_RED, 200), 1.f, 5); + ensure_equals("count is the latest", mScope.getSampleCount(), 50); + } + + // Degenerate input clears rather than reading past the buffer. + template<> template<> + void scope_object::test<10>() + { + const std::vector px = flat(60, 60, 60, 8); + mScope.accumulate(px.data(), 8, 1); + ensure("measured", !mScope.isEmpty()); + + mScope.accumulate(px.data(), 0, 1); + ensure("zero count clears", mScope.isEmpty()); + + mScope.accumulate(px.data(), 8, 1); + mScope.accumulate(nullptr, 8, 1); + ensure("null clears", mScope.isEmpty()); + + mScope.accumulate(px.data(), 8, 1); + mScope.accumulate(px.data(), -5, 1); + ensure("negative count clears", mScope.isEmpty()); + } + + // The first blend takes the new sample whole; fading up from zero would + // read as the scope being broken for the first few updates. + template<> template<> + void scope_object::test<11>() + { + ALScopeData fresh; + const std::vector px = flat(90, 90, 90, 20); + fresh.accumulate(px.data(), 20, 1); + + mScope.blendToward(fresh, 0.25f); + ensure_approximately_equals("taken whole", mScope.getBin(ALScopeData::CH_RED, 90), 1.f, 5); + ensure_equals("count carried", mScope.getSampleCount(), 20); + } + + // A later blend moves partway and converges on repetition. + template<> template<> + void scope_object::test<12>() + { + ALScopeData first; + ALScopeData second; + const std::vector a = flat(40, 40, 40, 20); + const std::vector b = flat(200, 200, 200, 20); + first.accumulate(a.data(), 20, 1); + second.accumulate(b.data(), 20, 1); + + mScope.blendToward(first, 1.f); + mScope.blendToward(second, 0.5f); + ensure_approximately_equals("halfway out of the old bin", + mScope.getBin(ALScopeData::CH_RED, 40), 0.5f, 4); + ensure_approximately_equals("halfway into the new one", + mScope.getBin(ALScopeData::CH_RED, 200), 0.5f, 4); + + for (S32 i = 0; i < 40; ++i) + { + mScope.blendToward(second, 0.5f); + } + ensure_approximately_equals("converges on the new sample", + mScope.getBin(ALScopeData::CH_RED, 200), 1.f, 4); + ensure_approximately_equals("and leaves the old", + mScope.getBin(ALScopeData::CH_RED, 40), 0.f, 4); + } + + // Blending never leaves the peak below a bin it will be asked to scale. + template<> template<> + void scope_object::test<13>() + { + ALScopeData first; + ALScopeData second; + const std::vector a = ramp(); + const std::vector b = flat(77, 77, 77, 256); + first.accumulate(a.data(), ALScopeData::BIN_COUNT, 1); + second.accumulate(b.data(), ALScopeData::BIN_COUNT, 1); + + mScope.blendToward(first, 1.f); + mScope.blendToward(second, 0.3f); + + for (S32 c = 0; c < ALScopeData::CH_COUNT; ++c) + { + const ALScopeData::EChannel ch = (ALScopeData::EChannel)c; + const F32 peak = mScope.getPeak(ch); + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + ensure("no bin exceeds the peak", mScope.getBin(ch, i) <= peak + 1e-6f); + } + } + } + + // Blending toward nothing keeps what is already measured, so closing and + // reopening the source does not wipe the display. + template<> template<> + void scope_object::test<14>() + { + const std::vector px = flat(150, 150, 150, 32); + mScope.accumulate(px.data(), 32, 1); + + const ALScopeData nothing; + mScope.blendToward(nothing, 0.5f); + ensure_approximately_equals("unchanged", mScope.getBin(ALScopeData::CH_RED, 150), 1.f, 5); + } + + // Out-of-range queries are answered, not asserted on. + template<> template<> + void scope_object::test<15>() + { + const std::vector px = flat(1, 2, 3, 4); + mScope.accumulate(px.data(), 4, 1); + + ensure_approximately_equals("negative bin", mScope.getBin(ALScopeData::CH_RED, -1), 0.f, 6); + ensure_approximately_equals("past the end", + mScope.getBin(ALScopeData::CH_RED, ALScopeData::BIN_COUNT), 0.f, 6); + ensure_approximately_equals("bad channel", + mScope.getBin((ALScopeData::EChannel)99, 0), 0.f, 6); + ensure_approximately_equals("bad channel peak", + mScope.getPeak((ALScopeData::EChannel)-3), 0.f, 6); + } + // --- vectorscope --------------------------------------------------------- + + // Grey has no chroma, so every neutral sample lands in the middle. That is + // the reading a colourist checks first: a trace off-centre at the origin + // means a cast. + template<> template<> + void scope_object::test<16>() + { + for (U8 level : { (U8)0, (U8)64, (U8)128, (U8)200, (U8)255 }) + { + mScope.accumulate(flat(level, level, level, 16).data(), 16, 1); + + S32 u = -1, v = -1; + soleCell(mScope, u, v); + // Two central cells straddle zero on an even grid; either is right. + ensure("grey sits at the centre in u", u == ALScopeData::CHROMA_SIZE / 2 || + u == ALScopeData::CHROMA_SIZE / 2 - 1); + ensure("grey sits at the centre in v", v == ALScopeData::CHROMA_SIZE / 2 || + v == ALScopeData::CHROMA_SIZE / 2 - 1); + } + } + + // The primaries land in the directions the wheels put them, so pushing a + // wheel and watching the trace agree is meaningful. Red at angle 0 means + // right of centre; green and blue at 120 and 240 degrees. + template<> template<> + void scope_object::test<17>() + { + const S32 mid = ALScopeData::CHROMA_SIZE / 2; + + mScope.accumulate(flat(255, 0, 0, 8).data(), 8, 1); + S32 u = -1, v = -1; + soleCell(mScope, u, v); + ensure("red is right of centre", u > mid); + ensure("and level with it", v == mid || v == mid - 1); + + mScope.accumulate(flat(0, 255, 0, 8).data(), 8, 1); + soleCell(mScope, u, v); + ensure("green is left of centre", u < mid); + ensure("and above it", v > mid); + + mScope.accumulate(flat(0, 0, 255, 8).data(), 8, 1); + soleCell(mScope, u, v); + ensure("blue is left of centre", u < mid); + ensure("and below it", v < mid); + } + + // Cells are shares of the sample, like the histogram's bins, so they sum + // to one and blend the same way. + template<> template<> + void scope_object::test<18>() + { + mScope.accumulate(ramp().data(), ALScopeData::BIN_COUNT, 1); + ensure_approximately_equals("cells sum to one", chromaSum(mScope), 1.f, 4); + ensure("peak is a real share", mScope.getChromaPeak() > 0.f && + mScope.getChromaPeak() <= 1.f); + + // A ramp is entirely neutral, so all of it is in one place. + ensure_approximately_equals("a grey ramp is a point", mScope.getChromaPeak(), 1.f, 4); + + ALScopeData red; + red.accumulate(flat(255, 0, 0, 8).data(), 8, 1); + mScope.blendToward(red, 0.5f); + ensure_approximately_equals("still sums to one after a blend", + chromaSum(mScope), 1.f, 4); + ensure_approximately_equals("and the peak matches the cells", + mScope.getChromaPeak(), 0.5f, 4); + } + + // Out-of-range cells answer zero rather than assert, and clear() empties + // the grid along with everything else. + template<> template<> + void scope_object::test<19>() + { + mScope.accumulate(flat(200, 30, 30, 4).data(), 4, 1); + ensure("something was measured", mScope.getChromaPeak() > 0.f); + + ensure_approximately_equals("negative u", mScope.getChromaCell(-1, 0), 0.f, 6); + ensure_approximately_equals("negative v", mScope.getChromaCell(0, -1), 0.f, 6); + ensure_approximately_equals("past the end in u", + mScope.getChromaCell(ALScopeData::CHROMA_SIZE, 0), 0.f, 6); + ensure_approximately_equals("past the end in v", + mScope.getChromaCell(0, ALScopeData::CHROMA_SIZE), 0.f, 6); + + mScope.clear(); + ensure_approximately_equals("cleared peak", mScope.getChromaPeak(), 0.f, 6); + ensure_approximately_equals("cleared cells", chromaSum(mScope), 0.f, 6); + } + + // Cell centres run -1 to 1 in units of MAX_CHROMA, and the binning is the + // inverse of that mapping -- so a plot laid out from chromaCellCentre puts + // the trace where accumulate() put it, not half a cell away. + template<> template<> + void scope_object::test<20>() + { + const S32 last = ALScopeData::CHROMA_SIZE - 1; + ensure("first cell is at the low edge", ALScopeData::chromaCellCentre(0) < -0.9f); + ensure("last cell is at the high edge", ALScopeData::chromaCellCentre(last) > 0.9f); + ensure_approximately_equals("the grid straddles zero", + ALScopeData::chromaCellCentre(ALScopeData::CHROMA_SIZE / 2) + + ALScopeData::chromaCellCentre(ALScopeData::CHROMA_SIZE / 2 - 1), + 0.f, 5); + for (S32 i = 1; i < ALScopeData::CHROMA_SIZE; ++i) + { + ensure("centres increase", + ALScopeData::chromaCellCentre(i) > ALScopeData::chromaCellCentre(i - 1)); + } + } + + // The whole reason the waveform exists: a histogram cannot tell a blown sky + // from a blown face, and this must. Left half black, right half white -- + // they have to land in different columns, at opposite ends of the scale. + template<> template<> + void scope_object::test<21>() + { + const S32 W = 64, H = 8; + std::vector px((size_t)W * H * 4, 255); + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const U8 v = (x < W / 2) ? 0 : 255; + const size_t i = ((size_t)y * W + x) * 4; + px[i + 0] = px[i + 1] = px[i + 2] = v; + px[i + 3] = 255; + } + } + mScope.accumulate(px.data(), W, H); + + const S32 top = ALScopeData::WAVE_LEVELS - 1; + const S32 left = ALScopeData::WAVE_COLUMNS / 4; // inside the black half + const S32 right = 3 * ALScopeData::WAVE_COLUMNS / 4; // inside the white half + + ensure_approximately_equals("black column is entirely at the bottom", + mScope.getWaveCell(ALScopeData::CH_LUMA, left, 0), 1.f, 5); + ensure_equals("black column has nothing at the top", + mScope.getWaveCell(ALScopeData::CH_LUMA, left, top), 0.f); + ensure_approximately_equals("white column is entirely at the top", + mScope.getWaveCell(ALScopeData::CH_LUMA, right, top), 1.f, 5); + ensure_equals("white column has nothing at the bottom", + mScope.getWaveCell(ALScopeData::CH_LUMA, right, 0), 0.f); + } + + // A left-to-right ramp is the shape everyone recognises: the trace climbs + // across the frame. If columns and levels were ever transposed, or the + // column mapping inverted, this is what catches it. + template<> template<> + void scope_object::test<22>() + { + const S32 W = ALScopeData::WAVE_COLUMNS, H = 4; + std::vector px((size_t)W * H * 4, 255); + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const U8 v = (U8)(x * 255 / (W - 1)); + const size_t i = ((size_t)y * W + x) * 4; + px[i + 0] = px[i + 1] = px[i + 2] = v; + px[i + 3] = 255; + } + } + mScope.accumulate(px.data(), W, H); + + // One value per column, so exactly one level per column is lit, and it + // must rise with the column. + S32 previous = -1; + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + S32 lit = -1; + for (S32 level = 0; level < ALScopeData::WAVE_LEVELS; ++level) + { + if (mScope.getWaveCell(ALScopeData::CH_LUMA, column, level) > 0.f) + { + ensure_equals("only one level per column", lit, -1); + lit = level; + } + } + ensure("every column is lit somewhere", lit >= 0); + ensure("the trace never descends", lit >= previous); + previous = lit; + } + ensure("and it does climb", previous > 0); + } + + // Shares are per column, not per sample -- that is what lets the plot's + // intensity mean the same thing whatever the sample's width was. So a flat + // frame reads 1.0 in every column, not 1/WAVE_COLUMNS. + template<> template<> + void scope_object::test<23>() + { + auto column_total = [&](S32 column) + { + F32 total = 0.f; + for (S32 level = 0; level < ALScopeData::WAVE_LEVELS; ++level) + { + total += mScope.getWaveCell(ALScopeData::CH_LUMA, column, level); + } + return total; + }; + + // Wider than the grid, which is the real case -- the sample is ~320px + // against 128 columns. Each column takes an unequal share of the source + // columns, so this only reads 1.0 if each is divided by its own count + // and not by the sample's height. + const S32 W = 2 * ALScopeData::WAVE_COLUMNS + 7, H = 5; + mScope.accumulate(flat(128, 128, 128, W * H).data(), W, H); + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + ensure_approximately_equals("every column holds one column's worth", + column_total(column), 1.f, 5); + } + ensure_approximately_equals("peak is a full column", + mScope.getWavePeak(ALScopeData::CH_LUMA), 1.f, 5); + + // Narrower than the grid: some columns have no pixels behind them at + // all. Those stay empty rather than dividing by zero, and the ones that + // do have pixels still read a full column. + const S32 NARROW = 40; + mScope.accumulate(flat(128, 128, 128, NARROW * H).data(), NARROW, H); + S32 filled = 0; + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + const F32 total = column_total(column); + if (total > 0.f) + { + ++filled; + ensure_approximately_equals("a filled column is still whole", total, 1.f, 5); + } + } + ensure_equals("one filled column per source column", filled, NARROW); + } + + // Channels are measured separately, which is the whole point of a parade: a + // cast shows as the three traces sitting at different heights. + template<> template<> + void scope_object::test<24>() + { + const S32 W = 32, H = 4; + mScope.accumulate(flat(255, 128, 0, W * H).data(), W, H); + + const S32 column = ALScopeData::WAVE_COLUMNS / 2; + const S32 top = ALScopeData::WAVE_LEVELS - 1; + + ensure_approximately_equals("red is at the top", + mScope.getWaveCell(ALScopeData::CH_RED, column, top), 1.f, 5); + ensure_approximately_equals("blue is at the bottom", + mScope.getWaveCell(ALScopeData::CH_BLUE, column, 0), 1.f, 5); + ensure_equals("red is not also at the bottom", + mScope.getWaveCell(ALScopeData::CH_RED, column, 0), 0.f); + ensure("green is at neither end", + mScope.getWaveCell(ALScopeData::CH_GREEN, column, 0) == 0.f && + mScope.getWaveCell(ALScopeData::CH_GREEN, column, top) == 0.f); + } + + // A plot walks the whole grid without checking, so out-of-range reads and a + // scope that has never measured anything both have to answer zero rather + // than index a vector that is deliberately empty until first use. + template<> template<> + void scope_object::test<25>() + { + ALScopeData fresh; + ensure_equals("unmeasured reads zero", fresh.getWaveCell(ALScopeData::CH_LUMA, 0, 0), 0.f); + ensure_equals("unmeasured has no peak", fresh.getWavePeak(ALScopeData::CH_LUMA), 0.f); + + mScope.accumulate(flat(200, 200, 200, 16).data(), 16, 1); + ensure_equals("negative column", mScope.getWaveCell(ALScopeData::CH_LUMA, -1, 0), 0.f); + ensure_equals("column past the end", + mScope.getWaveCell(ALScopeData::CH_LUMA, ALScopeData::WAVE_COLUMNS, 0), 0.f); + ensure_equals("level past the end", + mScope.getWaveCell(ALScopeData::CH_LUMA, 0, ALScopeData::WAVE_LEVELS), 0.f); + ensure_equals("bad channel", mScope.getWaveCell(ALScopeData::CH_COUNT, 0, 0), 0.f); + + // And clearing has to let go of the grid again. + mScope.clear(); + ensure_equals("cleared reads zero", mScope.getWaveCell(ALScopeData::CH_LUMA, 0, 0), 0.f); + ensure_equals("cleared has no peak", mScope.getWavePeak(ALScopeData::CH_LUMA), 0.f); + } +} diff --git a/indra/newview/tests/alwhitebalancesolver_test.cpp b/indra/newview/tests/alwhitebalancesolver_test.cpp new file mode 100644 index 0000000000..9b062e967d --- /dev/null +++ b/indra/newview/tests/alwhitebalancesolver_test.cpp @@ -0,0 +1,229 @@ +/** + * @file alwhitebalancesolver_test.cpp + * @brief Unit tests for the white-balance forward map and its inverse + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alwhitebalancesolver.h" + +#include + +namespace tut +{ + struct wb_data + { + /// How far apart two gains are, as the solver measures it: RMS of the + /// log ratios of the two free channels. Green is pinned to 1 on both + /// sides by construction, so it carries no information. + static F32 gainDistance(const LLVector3& a, const LLVector3& b) + { + const F32 dr = std::log(std::max(a.mV[VX], 1e-6f)) - std::log(std::max(b.mV[VX], 1e-6f)); + const F32 db = std::log(std::max(a.mV[VZ], 1e-6f)) - std::log(std::max(b.mV[VZ], 1e-6f)); + return std::sqrt((dr * dr + db * db) * 0.5f); + } + }; + + typedef test_group wb_group; + typedef wb_group::object wb_object; + tut::wb_group wbg("ALWhiteBalanceSolver"); + + // Neutral in, neutral out. The shader's identity fast path tests the gain + // against exactly vec3(1), so anything else here would leave the default + // settings paying for a colour transform that does nothing. + template<> template<> + void wb_object::test<1>() + { + const LLVector3 g = ALWhiteBalanceSolver::gain(0.f, 0.f); + ensure_approximately_equals("neutral red", g.mV[VX], 1.f, 6); + ensure_approximately_equals("neutral green", g.mV[VY], 1.f, 6); + ensure_approximately_equals("neutral blue", g.mV[VZ], 1.f, 6); + } + + // Green is pinned across the whole range, which is what makes temperature + // a colour control rather than an exposure one. + template<> template<> + void wb_object::test<2>() + { + for (S32 i = -5; i <= 5; ++i) + { + for (S32 j = -2; j <= 2; ++j) + { + const LLVector3 g = ALWhiteBalanceSolver::gain(i * 1000.f, j * 0.5f); + ensure_approximately_equals("green stays pinned", g.mV[VY], 1.f, 6); + } + } + } + + // The direction of the temperature control. A negative offset asks for a + // warmer scene, which the renderer delivers by pushing red up relative to + // blue -- if this ever inverts, every tooltip in the Basic panel is wrong. + template<> template<> + void wb_object::test<3>() + { + const LLVector3 warm = ALWhiteBalanceSolver::gain(-2500.f, 0.f); + const LLVector3 cool = ALWhiteBalanceSolver::gain( 2500.f, 0.f); + ensure("warm lifts red above blue", warm.mV[VX] > warm.mV[VZ]); + ensure("cool lifts blue above red", cool.mV[VZ] > cool.mV[VX]); + } + + // The round trip the eyedropper depends on: every usable pair is recovered + // from the gain it produces. + template<> template<> + void wb_object::test<4>() + { + S32 tested = 0; + for (S32 i = -4; i <= 4; ++i) + { + for (S32 j = -4; j <= 4; ++j) + { + const F32 cct = i * 1200.f; + const F32 duv = j * 0.25f; + + // Out of gamut is out of scope: down there the blue gain is + // negative, so every candidate looks alike to the solver and + // no inverse exists to test. Test <9> covers that region. + if (!ALWhiteBalanceSolver::isUsable(cct, duv)) + { + continue; + } + ++tested; + + const LLVector3 want = ALWhiteBalanceSolver::gain(cct, duv); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(want); + + // Compared on the gain, not on the parameters. The map is not + // equally sensitive everywhere -- a thousand Kelvin at the + // warm end moves the gain far less than at the cool end -- so + // a tolerance in Kelvin would be either slack where it matters + // or unachievable where it does not. What has to round-trip is + // the colour the renderer will actually apply. + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + ensure("gain round-trips", gainDistance(want, back) < 1e-3f); + ensure("and the solver knows it did", got.mResidual < 1e-3f); + } + } + // Guards against the skip above quietly emptying the sweep. + ensure("the sweep covered most of the range", tested > 60); + } + + // A solution never escapes the sliders' range, whatever it is asked for. + // Out here the answer is a best effort against the edge of the box, and + // the residual is how the caller finds out. + template<> template<> + void wb_object::test<5>() + { + // Both free channels pulled well below green: "make it much greener + // than any light source is". Temperature trades red against blue and + // tint moves them together but only so far, so this lies off the + // reachable surface entirely -- unlike, say, (6, 1, 0.05), which looks + // extreme and turns out to be very nearly on it. + const LLVector3 absurd(0.3f, 1.f, 0.3f); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(absurd); + + ensure("cct stays in range", + got.mCCTOffset >= ALWhiteBalanceSolver::CCT_MIN && + got.mCCTOffset <= ALWhiteBalanceSolver::CCT_MAX); + ensure("duv stays in range", + got.mDuv >= ALWhiteBalanceSolver::DUV_MIN && + got.mDuv <= ALWhiteBalanceSolver::DUV_MAX); + ensure("and it reports that it could not get there", got.mResidual > 0.1f); + } + + // The gain that neutralises a colour, and the sign of what it does. + template<> template<> + void wb_object::test<6>() + { + const LLVector3 g = ALWhiteBalanceSolver::neutralisingGain(LLColor3(0.4f, 0.5f, 0.8f)); + ensure_approximately_equals("green pinned", g.mV[VY], 1.f, 6); + ensure_approximately_equals("red is lifted to meet green", g.mV[VX], 1.25f, 5); + ensure_approximately_equals("blue is pulled down to it", g.mV[VZ], 0.625f, 5); + + // Applying it does what it says. + const LLColor3 sample(0.4f, 0.5f, 0.8f); + ensure_approximately_equals("corrected red equals green", sample.mV[0] * g.mV[VX], sample.mV[1], 5); + ensure_approximately_equals("corrected blue equals green", sample.mV[2] * g.mV[VZ], sample.mV[1], 5); + + // Scale-invariant: the eyedropper cares about the colour of a sample, + // never how brightly it was lit. + const LLVector3 dim = ALWhiteBalanceSolver::neutralisingGain(LLColor3(0.04f, 0.05f, 0.08f)); + ensure("brightness does not change the answer", gainDistance(g, dim) < 1e-4f); + } + + // The whole eyedropper, end to end: take a neutral surface, light it the + // way some (cct, duv) pair would, and the solver should recover the pair + // that undoes it. + template<> template<> + void wb_object::test<7>() + { + for (S32 i = -3; i <= 3; ++i) + { + const F32 cct = i * 1500.f; + + // A grey surface seen through the inverse of the correction: this + // is what the scene buffer holds when the light is that colour. + const LLVector3 correction = ALWhiteBalanceSolver::gain(cct, 0.f); + const LLColor3 lit(0.5f / correction.mV[VX], 0.5f, 0.5f / correction.mV[VZ]); + + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solveForColor(lit); + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + + ensure("recovers the light's own correction", + gainDistance(correction, back) < 1e-3f); + + // And the sample really does come out neutral. + const LLColor3 fixed(lit.mV[0] * back.mV[VX], lit.mV[1], lit.mV[2] * back.mV[VZ]); + ensure_approximately_equals("neutralised red", fixed.mV[0], fixed.mV[1], 4); + ensure_approximately_equals("neutralised blue", fixed.mV[2], fixed.mV[1], 4); + } + } + + // An already-neutral sample asks for nothing, so clicking a grey wall in + // a correctly balanced scene must not nudge the sliders off zero. + template<> template<> + void wb_object::test<8>() + { + const ALWhiteBalanceSolver::Result got = + ALWhiteBalanceSolver::solveForColor(LLColor3(0.5f, 0.5f, 0.5f)); + ensure("cct stays put", std::fabs(got.mCCTOffset) < 1.f); + ensure("duv stays put", std::fabs(got.mDuv) < 1e-3f); + ensure("exactly reachable", got.mResidual < 1e-4f); + } + + // The warm end of the Temperature slider leaves the sRGB gamut: below + // roughly 1900K the locus is outside it and the XYZ-to-sRGB matrix returns + // a negative blue gain, which multiplied into a frame flips the channel's + // sign. That is the renderer's existing behaviour and this fixes none of + // it; what is pinned here is that the solver stays out of the region, so + // the eyedropper can never hand a user a balance that does that. + template<> template<> + void wb_object::test<9>() + { + ensure("neutral is usable", ALWhiteBalanceSolver::isUsable(0.f, 0.f)); + ensure("the cool end is usable", ALWhiteBalanceSolver::isUsable(5000.f, 0.f)); + ensure("the warm extreme is not", !ALWhiteBalanceSolver::isUsable(-5000.f, 0.f)); + // Tint moves the boundary: pushing green costs gamut at the warm end. + ensure("-4000 is usable at neutral tint", ALWhiteBalanceSolver::isUsable(-4000.f, 0.f)); + ensure("but not at full green tint", !ALWhiteBalanceSolver::isUsable(-4000.f, 1.f)); + + // Ask for something only the out-of-gamut region could match, and the + // answer must still be a balance that works. + const LLVector3 want = ALWhiteBalanceSolver::gain(-5000.f, 0.f); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(want); + ensure("the solution is usable", ALWhiteBalanceSolver::isUsable(got.mCCTOffset, got.mDuv)); + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + ensure("its red gain is positive", back.mV[VX] > 0.f); + ensure("and so is its blue", back.mV[VZ] > 0.f); + } +} diff --git a/indra/newview/tests/lutcube_test.cpp b/indra/newview/tests/lutcube_test.cpp new file mode 100644 index 0000000000..b4f183c19e --- /dev/null +++ b/indra/newview/tests/lutcube_test.cpp @@ -0,0 +1,292 @@ +/** + * @file lutcube_test.cpp + * @brief Unit tests for the .cube LUT parser + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + * A .cube is untrusted input -- a file the user downloaded and pointed us at. + * Most of what is below is therefore about what the parser does with a file + * that is wrong, not one that is right. + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../lutcube.h" + +#include + +namespace tut +{ + struct lutcube_data + { + /// A well-formed identity cube of side @a n, optionally with @a extra + /// header lines (DOMAIN_MIN and friends) inserted after the size. + /// Entries run with red fastest, which is the .cube convention and the + /// order LutCube advances its cursor in. + static std::string identity(int n, const std::string& extra = std::string()) + { + std::ostringstream out; + out << "# generated by lutcube_test\n"; + out << "TITLE \"identity\"\n"; + out << "LUT_3D_SIZE " << n << "\n"; + if (!extra.empty()) + { + out << extra << "\n"; + } + const float last = (float)(n - 1); + for (int z = 0; z < n; ++z) + { + for (int y = 0; y < n; ++y) + { + for (int x = 0; x < n; ++x) + { + out << (F32)x / last << " " << (F32)y / last << " " << (F32)z / last << "\n"; + } + } + } + return out.str(); + } + + static LutCube parse(const std::string& text) + { + LutCube cube; + std::istringstream stream(text); + cube.parse(stream); + return cube; + } + + /// Value at entry @a index of channel @a channel (0 = R, 1 = G, 2 = B). + /// Entries are 16-bit, so full scale is 65535 and mid grey is 32768. + static int at(const LutCube& cube, int index, int channel) + { + return (int)cube.colorCube[(size_t)index * 4 + channel]; + } + + // constexpr, not const: ensure_equals takes its arguments by reference, + // which would odr-use a plain static const and want a definition. + static constexpr int FULL = 65535; + static constexpr int MID = 32768; + }; + + typedef test_group lutcube_group; + typedef lutcube_group::object lutcube_object; + tut::lutcube_group lcg("LutCube"); + + // The ordinary case, and the layout the uploader depends on: red varies + // fastest, so entry 1 of a size-2 cube is (FULL, 0, 0). + template<> template<> + void lutcube_object::test<1>() + { + const LutCube cube = parse(identity(2)); + ensure_equals("size", cube.size, 2); + ensure_equals("entry count", (int)cube.colorCube.size(), 2 * 2 * 2 * 4); + + ensure_equals("black R", at(cube, 0, 0), 0); + ensure_equals("black G", at(cube, 0, 1), 0); + ensure_equals("black B", at(cube, 0, 2), 0); + + ensure_equals("red is next R", at(cube, 1, 0), FULL); + ensure_equals("red is next G", at(cube, 1, 1), 0); + ensure_equals("red is next B", at(cube, 1, 2), 0); + + ensure_equals("white is last R", at(cube, 7, 0), FULL); + ensure_equals("white is last G", at(cube, 7, 1), FULL); + ensure_equals("white is last B", at(cube, 7, 2), FULL); + } + + // Quantisation rounds rather than truncates. 0.5 * 65535 is 32767.5; + // truncating biases every entry in the cube downwards by up to one step, + // which on an identity LUT is a darkening for no reason at all. + template<> template<> + void lutcube_object::test<2>() + { + const LutCube cube = parse(identity(3)); + ensure_equals("size", cube.size, 3); + // Entry 1 is x = 1 of 2, i.e. exactly mid grey on red alone. + ensure_equals("mid rounds up", at(cube, 1, 0), MID); + } + + // DOMAIN_MIN was parsed and then ignored: the original divided by the span + // but never subtracted the offset. With a domain of [-1, 1], an input of 0 + // is the middle of the domain and must land on mid grey -- the old code put + // it at 0. + template<> template<> + void lutcube_object::test<3>() + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "DOMAIN_MIN -1.0 -1.0 -1.0\n"; + text << "DOMAIN_MAX 1.0 1.0 1.0\n"; + for (int i = 0; i < 8; ++i) + { + text << "0.0 0.0 0.0\n"; + } + + const LutCube cube = parse(text.str()); + ensure_equals("size", cube.size, 2); + ensure_equals("domain centre is mid grey", at(cube, 0, 0), MID); + ensure_equals("domain centre is mid grey", at(cube, 0, 1), MID); + ensure_equals("domain centre is mid grey", at(cube, 0, 2), MID); + } + + // The one that shows up as an artefact rather than a failure. "clampTripel" + // did not clamp, so 1.1 became 255 * 1.1 = 280, which wrapped to 24 on the + // cast -- a blown highlight rendered as near-black speckle. Below the + // domain wrapped the other way. + template<> template<> + void lutcube_object::test<4>() + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + for (int i = 0; i < 8; ++i) + { + text << "1.1 -0.4 0.5\n"; + } + + const LutCube cube = parse(text.str()); + ensure_equals("above domain clamps to white", at(cube, 0, 0), FULL); + ensure_equals("below domain clamps to black", at(cube, 0, 1), 0); + ensure_equals("in domain is untouched", at(cube, 0, 2), MID); + } + + // Legal .cube files indent their data, and the original required a digit at + // column zero. A rejected row does not merely lose itself: every later row + // slides up into its slot, so the whole cube is wrong and the tail is left + // at the full-scale fill. Indented, signed and bare-decimal rows all count. + template<> template<> + void lutcube_object::test<5>() + { + const LutCube plain = parse(identity(2)); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << " 0.0 0.0 0.0\n"; // leading spaces + text << "\t+1.0 0.0 0.0\n"; // tab, and an explicit plus + text << " .0 1.0 0.0\n"; // bare decimal point + text << "1.0 1.0 0.0\n"; + text << "0.0 0.0 1.0\n"; + text << "1.0 0.0 1.0\n"; + text << "0.0 1.0 1.0\n"; + text << "1.0 1.0 1.0\n"; + + const LutCube indented = parse(text.str()); + ensure_equals("indented cube is accepted", indented.size, 2); + ensure("indented cube matches the plain one", indented.colorCube == plain.colorCube); + } + + // The memory-safety one. With no LUT_3D_SIZE the cube is never allocated and + // size stays zero, and the original wrote the first data row into it + // regardless. Rejecting the file is the whole fix; the assertion here is + // that we get an empty cube back and not a crash. + template<> template<> + void lutcube_object::test<6>() + { + const LutCube cube = parse("0.0 0.0 0.0\n1.0 1.0 1.0\n"); + ensure("data before LUT_3D_SIZE is rejected", cube.colorCube.empty()); + ensure_equals("and leaves no size", cube.size, 0); + } + + // A short file used to be kept. The allocation is prefilled with full + // scale, so everything past the last row read came out white -- an "almost + // working" LUT that blows out the top of the image. + template<> template<> + void lutcube_object::test<7>() + { + std::string text = identity(2); + text.erase(text.find_last_of('\n', text.size() - 2) + 1); + + const LutCube cube = parse(text); + ensure("an incomplete cube is rejected", cube.colorCube.empty()); + } + + // stof/stoi throw, and nothing used to catch them, so a corrupt file threw + // out of setupGradingLUT rather than falling back. + template<> template<> + void lutcube_object::test<8>() + { + const LutCube bad_size = parse("LUT_3D_SIZE banana\n"); + ensure("a non-numeric size is rejected", bad_size.colorCube.empty()); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "0.0 0.0 0.0\n"; + text << "0.5 what 0.5\n"; + const LutCube bad_row = parse(text.str()); + ensure("a malformed row is rejected", bad_row.colorCube.empty()); + } + + // An absurd size should be refused before it is allocated, not after. + template<> template<> + void lutcube_object::test<9>() + { + ensure("a huge size is rejected", parse("LUT_3D_SIZE 100000\n").colorCube.empty()); + ensure("a degenerate size is rejected", parse("LUT_3D_SIZE 1\n").colorCube.empty()); + ensure("a negative size is rejected", parse("LUT_3D_SIZE -4\n").colorCube.empty()); + } + + // Values separated by runs of spaces or by tabs, and a file with CRLF line + // endings read somewhere that does not strip the CR. The original split on + // the first " \n" it found and lost a value to any of these. + template<> template<> + void lutcube_object::test<10>() + { + const LutCube plain = parse(identity(2)); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\r\n"; + text << "0.0\t0.0\t0.0\r\n"; + text << "1.0 0.0 0.0\r\n"; + text << "0.0 1.0 0.0\r\n"; + text << "1.0 1.0 0.0\r\n"; + text << "0.0 0.0 1.0\r\n"; + text << "1.0 0.0 1.0\r\n"; + text << "0.0 1.0 1.0\r\n"; + text << "1.0 1.0 1.0\r\n"; + + const LutCube odd = parse(text.str()); + ensure_equals("odd whitespace is accepted", odd.size, 2); + ensure("odd whitespace matches the plain cube", odd.colorCube == plain.colorCube); + } + + // A value the text can carry but the arithmetic cannot survive. Whether a + // stream accepts "nan" or "inf" as a float varies by standard library, so + // the parser must reject them itself rather than lean on the extraction + // failing: a NaN sails through llclamp (both comparisons are false) and + // the cast to unsigned short is undefined behaviour. + template<> template<> + void lutcube_object::test<11>() + { + static const char* const poison[] = { "nan", "inf", "-inf" }; + for (const char* value : poison) + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "1.0 " << value << " 0.5\n"; + for (int i = 0; i < 7; ++i) + { + text << "0.0 0.0 0.0\n"; + } + ensure(std::string("a ") + value + " value is rejected", + parse(text.str()).colorCube.empty()); + } + + // The domain lines go through the same reader, and a non-finite domain + // poisons the quantisation of every row instead of just one. + std::ostringstream domain; + domain << "LUT_3D_SIZE 2\n"; + domain << "DOMAIN_MAX inf inf inf\n"; + for (int i = 0; i < 8; ++i) + { + domain << "0.0 0.0 0.0\n"; + } + ensure("a non-finite domain is rejected", parse(domain.str()).colorCube.empty()); + } +} From 48f03e4cf04e5b4df89d33dcd6f93d55cfabb4fc Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:42:36 -0500 Subject: [PATCH 22/32] Show up to four scopes at once, each pane assigned by right-click The scopes answer different questions about the same frame -- a histogram says how much of it sits at a level, a waveform says where, a vectorscope says what colour -- so the useful thing is to see several together rather than to cycle between them one at a time. The plot area now divides into one, two or four panes. AlchemyScopeLayout picks the arrangement and AlchemyScopePane0..3 say what each pane holds; right-clicking a pane sets its own. computePaneRects is the only place that divides the area, and it returns rects in the floater's coordinate space -- what draw() paints in and what handleRightMouseDown is given -- so the rect that drew a pane is the rect that hit-tests it. The three plot functions take (mode, rect) rather than reading the mode and the panel themselves. This costs nothing to measure. accumulate() already filled every channel, the chroma grid and the waveform grid on each capture whatever was on screen, so a fourth pane adds drawing and nothing else. Drawing is lopsided though: a waveform is WAVE_COLUMNS x WAVE_LEVELS cells per channel against a histogram's 256 bins, which is why four is a ceiling rather than a step. Defaults are the old behaviour exactly -- one pane, RGB histogram -- so upgrading changes nothing until the layout is switched. Spawning the menu needed LLContextMenu::show rather than the LLMenuGL::showPopup the neighbouring code uses. LLContextMenu overrides setVisible to ignore anything but false, and showPopup's only attempt to reveal a menu is setVisible(true), so it silently did nothing: the menu loaded, parented, populated and resolved its callbacks, and never appeared. show() takes screen coordinates where a mouse handler is given local ones. Both traps are now in doc/LIGHTBOX.md, which is also where the rule that a new scope is three edits and not one now lives. Build: 0 warnings. ctest 131/131. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 49 +++ indra/newview/alfloaterscopes.cpp | 284 ++++++++++++++++-- indra/newview/alfloaterscopes.h | 77 ++++- .../newview/app_settings/settings_alchemy.xml | 55 ++++ .../skins/default/xui/en/floater_scopes.xml | 63 ++-- .../skins/default/xui/en/menu_scopes_pane.xml | 74 +++++ 6 files changed, 534 insertions(+), 68 deletions(-) create mode 100644 indra/newview/skins/default/xui/en/menu_scopes_pane.xml diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index b291f1e157..ff88bc271b 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -318,6 +318,47 @@ a histogram says **how much** of the frame is at a level, a waveform says **where** it is (a blown sky and a blown face are the same histogram bin and obviously different waveforms), and a vectorscope says **what colour**. +Because they answer different questions, the floater shows up to four at once. +`AlchemyScopeLayout` picks the arrangement (single, two side by side, two +stacked, four in a grid) and `AlchemyScopePane0` through `AlchemyScopePane3` +say what each pane holds; right-clicking a pane sets its own. `computePaneRects` +is the single place that divides the plot area, and it returns rects in the +floater's coordinate space — the same space `draw()` paints in and +`handleRightMouseDown` is handed — so the rect that drew a pane is the rect +that hit-tests it. If those ever diverge, the menu opens on the wrong pane. + +The draw functions therefore take `(mode, rect)` rather than reading the mode +and the panel themselves. Anything new must too: a scope that reaches for +`mPlotPanel->getRect()` draws over all four panes. + +**Adding a scope is three edits, not one.** A new `EMode` needs an entry in +`modeStringName`, a `floater.string` for its corner label, and an item in +`menu_scopes_pane.xml` — the menu is hand-written rather than generated from the +enum, so a mode added without one is reachable only by editing the setting. +Keep `MODE_COUNT` last: `getPaneMode` clamps against it, and that clamp is what +stops a stale setting from indexing off the end. + +**Spawn an `LLContextMenu` with `show()`, never `LLMenuGL::showPopup()`.** +`LLContextMenu` overrides `setVisible` to ignore everything except `false`: + +```cpp +void LLContextMenu::setVisible(bool visible) { if (!visible) hide(); } +``` + +`showPopup`'s only attempt to reveal a menu is `setVisible(true)`, so against a +context menu it does nothing — silently. The menu still loads, parents, +populates and resolves its callbacks, so there is no warning in the log and +nothing to find at the point of failure; it simply never appears. Copying the +spawn code from a view that uses a plain `LLMenuGL` (`llnetmap` is the obvious +one to reach for) walks straight into this, because that call is correct +*there*. `LLContextMenu::show` also does its own arranging and edge-flipping, +so it replaces `showPopup` rather than joining it. + +Its coordinates are **screen** space — it calls `screenPointToLocal` internally +— while `handleRightMouseDown` is handed coordinates local to the view. Convert +with `localPointToScreen` or the menu opens in the wrong place on any window +that is not at the screen origin, which is easy to miss when testing maximised. + Two rules if you add another: - The vectorscope bins through `ALColorWheelModel::toChroma`, the same basis the @@ -334,6 +375,14 @@ whole `ALScopeData` as a **stack local**, and a member array that size would put it on the stack every capture. It is also empty until something is measured, so a viewer whose scopes have never been opened pays nothing. +Note what the pane layout does *not* cost. `accumulate` fills every channel, the +chroma grid and the waveform grid on each capture whatever is displayed, so a +fourth pane adds drawing and nothing else — no extra sampling, read-back or +binning. Drawing is where it is lopsided: a histogram is `BIN_COUNT` bins, but a +waveform is `WAVE_COLUMNS * WAVE_LEVELS` cells **per channel**, so a parade pane +is worth roughly two hundred histograms. Four is the ceiling for that reason, +not because the tiling could not go further. + And capture is gated on the floater existing (`LLPipeline::sScopeCapture`), so a closed window costs nothing at all. That gate is worth reusing: the cursor readout gets its value from `LLPipeline::getScopePixel`, which is a lookup into diff --git a/indra/newview/alfloaterscopes.cpp b/indra/newview/alfloaterscopes.cpp index 656aeb57ed..e77c4de296 100644 --- a/indra/newview/alfloaterscopes.cpp +++ b/indra/newview/alfloaterscopes.cpp @@ -28,14 +28,18 @@ #include "alcolorwheelmodel.h" #include "llcombobox.h" +#include "llfontgl.h" #include "lllocalcliprect.h" +#include "llmenugl.h" #include "llpanel.h" #include "llrender.h" #include "llrender2dutils.h" #include "lltextbox.h" #include "lltrans.h" #include "lluicolortable.h" +#include "lluictrlfactory.h" #include "llviewercontrol.h" +#include "llviewermenu.h" // gMenuHolder #include "llviewerwindow.h" #include "pipeline.h" @@ -54,11 +58,23 @@ const LLColor4 CHANNEL_COLOR[ALScopeData::CH_COUNT] = { /// the sample still draws about a fifth of the plot's height -- tall enough to /// see, short enough not to be mistaken for a peak. constexpr F32 LOG_SCALE_K = 400.f; + +/// Space between panes. Wide enough to read as a division rather than a drawing +/// artefact, narrow enough not to eat a small window: at the floater's minimum +/// size a quad layout has about sixty pixels of plot height per pane, and this +/// takes two of them. +constexpr S32 PANE_GAP = 4; } ALFloaterScopes::ALFloaterScopes(const LLSD& key) : LLFloater(key) { + // Registered here rather than in postBuild because the context menu is + // built from XML during postBuild and resolves these names as it parses. + mCommitCallbackRegistrar.add("Scopes.SetPaneMode", + boost::bind(&ALFloaterScopes::onPaneModePicked, this, _2)); + mEnableCallbackRegistrar.add("Scopes.IsPaneMode", + boost::bind(&ALFloaterScopes::isPaneModeChecked, this, _2)); } ALFloaterScopes::~ALFloaterScopes() @@ -66,15 +82,27 @@ ALFloaterScopes::~ALFloaterScopes() // Belt and braces: onClose already cleared it, but a floater destroyed // without closing would otherwise leave the renderer sampling forever. LLPipeline::sScopeCapture = false; + + // The menu lives in gMenuHolder, not in this floater's view tree, so + // nothing else is going to take it down with us. + if (auto* menu = static_cast(mPopupMenuHandle.get())) + { + menu->die(); + mPopupMenuHandle.markDead(); + } } bool ALFloaterScopes::postBuild() { mPlotPanel = getChild("scope_plot"); - mModeCombo = getChild("scope_mode"); + mLayoutCombo = getChild("scope_layout"); mClipReadout = getChild("scope_clipping"); - mModeCombo->selectByValue(LLSD((S32)MODE_RGB)); + if (auto* menu = LLUICtrlFactory::getInstance()->createFromFile( + "menu_scopes_pane.xml", gMenuHolder, LLViewerMenuHolderGL::child_registry_t::instance())) + { + mPopupMenuHandle = menu->getHandle(); + } return LLFloater::postBuild(); } @@ -91,9 +119,126 @@ void ALFloaterScopes::onClose(bool app_quitting) LLFloater::onClose(app_quitting); } -ALFloaterScopes::EMode ALFloaterScopes::getMode() const +ALFloaterScopes::ELayout ALFloaterScopes::getLayout() const +{ + static LLCachedControl layout(gSavedSettings, "AlchemyScopeLayout", LAYOUT_SINGLE); + // Clamped rather than trusted: the setting is user-editable and persisted, + // and a bad value here would index the pane table out of range. + return (ELayout)llclamp(layout(), (S32)LAYOUT_SINGLE, (S32)LAYOUT_COUNT - 1); +} + +S32 ALFloaterScopes::getPaneCount() const +{ + switch (getLayout()) + { + case LAYOUT_COLUMNS: + case LAYOUT_ROWS: + return 2; + case LAYOUT_QUAD: + return MAX_PANES; + case LAYOUT_SINGLE: + default: + return 1; + } +} + +ALFloaterScopes::EMode ALFloaterScopes::getPaneMode(S32 pane) const +{ + if (pane < 0 || pane >= MAX_PANES) + { + return MODE_RGB; + } + // One control per pane rather than one packed value, so each is separately + // readable and editable in Debug Settings like every other scope control. + const std::string name = llformat("AlchemyScopePane%d", pane); + return (EMode)llclamp(gSavedSettings.getS32(name), (S32)MODE_RGB, (S32)MODE_COUNT - 1); +} + +void ALFloaterScopes::setPaneMode(S32 pane, EMode mode) +{ + if (pane < 0 || pane >= MAX_PANES || mode < MODE_RGB || mode >= MODE_COUNT) + { + return; + } + gSavedSettings.setS32(llformat("AlchemyScopePane%d", pane), (S32)mode); +} + +// static +const char* ALFloaterScopes::modeStringName(EMode mode) +{ + switch (mode) + { + case MODE_LUMA: return "mode_luma"; + case MODE_RED: return "mode_red"; + case MODE_GREEN: return "mode_green"; + case MODE_BLUE: return "mode_blue"; + case MODE_VECTOR: return "mode_vector"; + case MODE_WAVE_LUMA: return "mode_wave_luma"; + case MODE_WAVE_RGB: return "mode_wave_rgb"; + case MODE_PARADE: return "mode_parade"; + case MODE_RGB: + default: return "mode_rgb"; + } +} + +S32 ALFloaterScopes::computePaneRects(LLRect (&out)[MAX_PANES]) const +{ + if (!mPlotPanel) + { + return 0; + } + + const LLRect area = mPlotPanel->getRect(); + const S32 count = getPaneCount(); + + // Halves are taken from the far edge rather than by adding the first half's + // width, so an odd number of pixels lands in one pane instead of leaving a + // one-pixel strip of background between them. + const S32 mid_x = area.mLeft + (area.getWidth() - PANE_GAP) / 2; + const S32 mid_y = area.mBottom + (area.getHeight() - PANE_GAP) / 2; + + switch (getLayout()) + { + case LAYOUT_COLUMNS: + out[0] = LLRect(area.mLeft, area.mTop, mid_x, area.mBottom); + out[1] = LLRect(mid_x + PANE_GAP, area.mTop, area.mRight, area.mBottom); + break; + + case LAYOUT_ROWS: + // Pane 0 on top: panes read in the order you assign them, and the eye + // starts at the top of a window rather than the bottom. + out[0] = LLRect(area.mLeft, area.mTop, area.mRight, mid_y + PANE_GAP); + out[1] = LLRect(area.mLeft, mid_y, area.mRight, area.mBottom); + break; + + case LAYOUT_QUAD: + out[0] = LLRect(area.mLeft, area.mTop, mid_x, mid_y + PANE_GAP); + out[1] = LLRect(mid_x + PANE_GAP, area.mTop, area.mRight, mid_y + PANE_GAP); + out[2] = LLRect(area.mLeft, mid_y, mid_x, area.mBottom); + out[3] = LLRect(mid_x + PANE_GAP, mid_y, area.mRight, area.mBottom); + break; + + case LAYOUT_SINGLE: + default: + out[0] = area; + break; + } + + return count; +} + +S32 ALFloaterScopes::paneAt(S32 x, S32 y) const { - return mModeCombo ? (EMode)mModeCombo->getValue().asInteger() : MODE_RGB; + LLRect rects[MAX_PANES]; + const S32 count = computePaneRects(rects); + for (S32 i = 0; i < count; ++i) + { + if (rects[i].pointInRect(x, y)) + { + return i; + } + } + return -1; } // static @@ -186,10 +331,8 @@ void ALFloaterScopes::drawChannel(const ALScopeData& data, ALScopeData::EChannel gl_polyline_2d(outline, edge, filled ? 1.2f : 1.6f); } -void ALFloaterScopes::drawHistogram(const ALScopeData& data) const +void ALFloaterScopes::drawHistogram(const ALScopeData& data, EMode mode, const LLRect& plot) const { - const LLRect plot = mPlotPanel->getRect(); - gl_rect_2d(plot, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); // Quarter-tone guides. Photographers reach for these constantly -- "is @@ -203,7 +346,7 @@ void ALFloaterScopes::drawHistogram(const ALScopeData& data) const { LLLocalClipRect clip(plot); - switch (getMode()) + switch (mode) { case MODE_RGB: // Additive so overlapping channels brighten towards white where @@ -221,7 +364,7 @@ void ALFloaterScopes::drawHistogram(const ALScopeData& data) const case MODE_GREEN: case MODE_BLUE: { - const auto ch = (ALScopeData::EChannel)(getMode() - MODE_RED); + const auto ch = (ALScopeData::EChannel)(mode - MODE_RED); drawChannel(data, ch, CHANNEL_COLOR[ch], plot, true); break; } @@ -293,10 +436,8 @@ void ALFloaterScopes::drawWaveChannel(const ALScopeData& data, ALScopeData::ECha gGL.flush(); } -void ALFloaterScopes::drawWaveform(const ALScopeData& data) const +void ALFloaterScopes::drawWaveform(const ALScopeData& data, EMode mode, const LLRect& panel) const { - const LLRect panel = mPlotPanel->getRect(); - gl_rect_2d(panel, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); // Guides run horizontally here, not vertically as on the histogram: on a @@ -311,7 +452,7 @@ void ALFloaterScopes::drawWaveform(const ALScopeData& data) const { LLLocalClipRect clip(panel); - switch (getMode()) + switch (mode) { case MODE_PARADE: { @@ -442,10 +583,8 @@ const LLColor4& cellTint(S32 u, S32 v) } } // namespace -void ALFloaterScopes::drawVectorscope(const ALScopeData& data) const +void ALFloaterScopes::drawVectorscope(const ALScopeData& data, const LLRect& panel) const { - const LLRect panel = mPlotPanel->getRect(); - gl_rect_2d(panel, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); // Square and centred. The chroma plane is isotropic -- a hue is a @@ -546,6 +685,100 @@ void ALFloaterScopes::drawVectorscope(const ALScopeData& data) const gl_rect_2d(panel, LLUIColorTable::instance().getColor("DefaultShadowLight").get(), false); } +void ALFloaterScopes::drawScope(const ALScopeData& data, EMode mode, const LLRect& rect) const +{ + if (rect.getWidth() <= 0 || rect.getHeight() <= 0) + { + return; + } + + if (mode == MODE_VECTOR) + { + drawVectorscope(data, rect); + } + else if (isWaveMode(mode)) + { + drawWaveform(data, mode, rect); + } + else + { + drawHistogram(data, mode, rect); + } +} + +void ALFloaterScopes::drawPaneLabel(EMode mode, const LLRect& rect) const +{ + // Only when there is more than one pane. With a single scope filling the + // window the layout combo already says what it is, and the label would be + // ink on the plot for nothing. + if (getPaneCount() < 2) + { + return; + } + + const LLFontGL* font = LLFontGL::getFontSansSerifSmall(); + if (!font) + { + return; + } + + // Dim rather than full strength: this identifies the pane, it is not part + // of the measurement, and a bright label competes with the trace. + LLColor4 color = LLUIColorTable::instance().getColor("TextFgReadOnlyColor").get(); + color.mV[VALPHA] = 0.65f; + + // Ellipsised to the pane rather than clipped to it. These names are + // translated, and a longer language would otherwise run a label out of its + // own pane and across the trace in the one beside it. + font->renderUTF8(getString(modeStringName(mode)), 0, + (F32)(rect.mLeft + 4), (F32)(rect.mTop - 2), + color, LLFontGL::LEFT, LLFontGL::TOP, LLFontGL::NORMAL, + LLFontGL::DROP_SHADOW_SOFT, + S32_MAX, rect.getWidth() - 8, nullptr, true); +} + +bool ALFloaterScopes::handleRightMouseDown(S32 x, S32 y, MASK mask) +{ + const S32 pane = paneAt(x, y); + if (pane < 0) + { + return LLFloater::handleRightMouseDown(x, y, mask); + } + + auto* menu = static_cast(mPopupMenuHandle.get()); + if (!menu) + { + return LLFloater::handleRightMouseDown(x, y, mask); + } + + mMenuPane = pane; + menu->buildDrawLabels(); + menu->updateParent(LLMenuGL::sMenuContainer); + + // LLContextMenu::show, not LLMenuGL::showPopup. LLContextMenu overrides + // setVisible to ignore anything but false ("can't set visibility directly, + // must call show or hide"), and showPopup's only attempt to reveal a menu + // is setVisible(true) -- so it silently does nothing here. Everything else + // showPopup would have done, show() does for itself. + // + // show() takes screen coordinates; a mouse handler is given coordinates + // local to the view it landed on. + S32 screen_x, screen_y; + localPointToScreen(x, y, &screen_x, &screen_y); + menu->show(screen_x, screen_y, this); + return true; +} + +void ALFloaterScopes::onPaneModePicked(const LLSD& userdata) +{ + setPaneMode(mMenuPane, (EMode)userdata.asInteger()); +} + +bool ALFloaterScopes::isPaneModeChecked(const LLSD& userdata) const +{ + return mMenuPane >= 0 && getPaneMode(mMenuPane) == (EMode)userdata.asInteger(); +} + void ALFloaterScopes::draw() { if (mPlotPanel) @@ -559,18 +792,15 @@ void ALFloaterScopes::draw() if (mPlotPanel) { - const EMode mode = getMode(); - if (mode == MODE_VECTOR) - { - drawVectorscope(gPipeline.getScopeData()); - } - else if (isWaveMode(mode)) - { - drawWaveform(gPipeline.getScopeData()); - } - else + const ALScopeData& data = gPipeline.getScopeData(); + + LLRect rects[MAX_PANES]; + const S32 count = computePaneRects(rects); + for (S32 i = 0; i < count; ++i) { - drawHistogram(gPipeline.getScopeData()); + const EMode mode = getPaneMode(i); + drawScope(data, mode, rects[i]); + drawPaneLabel(mode, rects[i]); } } } diff --git a/indra/newview/alfloaterscopes.h b/indra/newview/alfloaterscopes.h index 10f0699189..43c4a865c4 100644 --- a/indra/newview/alfloaterscopes.h +++ b/indra/newview/alfloaterscopes.h @@ -52,6 +52,20 @@ class LLTextBox; * reserves the area in XUI and the floater draws the plot into its rect. A * bespoke widget would have bought nothing here -- there is one consumer and * it is this file. + * + * @par Panes + * That one reserved rect is then divided into up to four panes, each showing + * whichever scope it has been assigned. The scopes answer different questions + * about the same frame -- how much, where, what colour -- so the useful thing + * is to see several at once rather than to cycle between them. + * + * This costs nothing to measure. @ref ALScopeData::accumulate fills every + * channel, the chroma grid and the waveform grid on each capture whatever is + * on screen, so a fourth pane adds drawing and nothing else. Drawing is not + * free though, and it is lopsided: a histogram is 256 bins, but a waveform is + * @c WAVE_COLUMNS x @c WAVE_LEVELS cells *per channel*, so a pane showing a + * parade does roughly two hundred times the work of one showing a histogram. + * That is the reason the layout tops out at four. */ class ALFloaterScopes final : public LLFloater { @@ -63,6 +77,7 @@ class ALFloaterScopes final : public LLFloater void onOpen(const LLSD& key) override; void onClose(bool app_quitting) override; void draw() override; + bool handleRightMouseDown(S32 x, S32 y, MASK mask) override; private: /// What the plot shows: RGB overlaid, one channel on its own, or the @@ -86,16 +101,56 @@ class ALFloaterScopes final : public LLFloater /// The three channels side by side rather than overlaid, which is how /// a cast is read: the traces sit at visibly different heights. MODE_PARADE, + MODE_COUNT, + }; + + /// How the plot area is divided. The order is the combo's order, and the + /// values are persisted, so append rather than insert. + enum ELayout + { + LAYOUT_SINGLE = 0, + LAYOUT_COLUMNS, + LAYOUT_ROWS, + LAYOUT_QUAD, + LAYOUT_COUNT, }; - EMode getMode() const; - bool useLogScale() const; + /// Four is a deliberate ceiling, not a spare-capacity number -- see the + /// class comment on what a parade pane costs to draw. + static constexpr S32 MAX_PANES = 4; + + ELayout getLayout() const; + /// Panes the current layout shows, 1 to MAX_PANES. + S32 getPaneCount() const; + /// The scope assigned to @a pane. Out-of-range panes read MODE_RGB rather + /// than assert: a stale setting must not be able to crash the floater. + EMode getPaneMode(S32 pane) const; + void setPaneMode(S32 pane, EMode mode); + + /// Divides the plot panel into the active layout's panes, in pane-index + /// order, and returns how many it wrote. Rects are in the floater's own + /// coordinate space, which is both what draw() paints in and what + /// handleRightMouseDown is given -- so the same rects hit-test and draw. + S32 computePaneRects(LLRect (&out)[MAX_PANES]) const; + /// Index of the pane containing (@a x, @a y), or -1 for none. + S32 paneAt(S32 x, S32 y) const; + + /// XUI string name for a scope's display name, for the pane's corner label + /// and nothing else. Localisable because it is shown to the user. + static const char* modeStringName(EMode mode); + + bool useLogScale() const; /// True for the modes drawn as a waveform rather than a histogram. static bool isWaveMode(EMode mode); - void drawHistogram(const ALScopeData& data) const; - void drawVectorscope(const ALScopeData& data) const; - void drawWaveform(const ALScopeData& data) const; + /// Draws @a mode into @a rect. The one place that knows which of the three + /// plot kinds a mode belongs to. + void drawScope(const ALScopeData& data, EMode mode, const LLRect& rect) const; + void drawPaneLabel(EMode mode, const LLRect& rect) const; + + void drawHistogram(const ALScopeData& data, EMode mode, const LLRect& plot) const; + void drawVectorscope(const ALScopeData& data, const LLRect& panel) const; + void drawWaveform(const ALScopeData& data, EMode mode, const LLRect& panel) const; void drawChannel(const ALScopeData& data, ALScopeData::EChannel channel, const LLColor4& color, const LLRect& plot, bool filled) const; /// One channel's waveform into @a plot. Separate from drawChannel because @@ -110,9 +165,19 @@ class ALFloaterScopes final : public LLFloater /// everything else into the axis, which is why photo tools offer both. F32 barHeight(F32 share, F32 peak) const; + /// Right-click assigns a scope to a pane, so the menu needs to know which + /// pane it was opened over. Set by handleRightMouseDown before the menu is + /// shown, and read by the menu's callbacks. + void onPaneModePicked(const LLSD& userdata); + bool isPaneModeChecked(const LLSD& userdata) const; + LLPanel* mPlotPanel = nullptr; - LLComboBox* mModeCombo = nullptr; + LLComboBox* mLayoutCombo = nullptr; LLTextBox* mClipReadout = nullptr; + + LLHandle mPopupMenuHandle; + /// Which pane the open context menu is acting on; -1 when none is. + S32 mMenuPane = -1; }; #endif // AL_FLOATERSCOPES_H diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index ade77e5821..e5819b717a 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -1184,6 +1184,61 @@ Value 1 + AlchemyScopeLayout + + Comment + Scopes floater: how many scopes are shown at once and how they are arranged. 0 = one filling the window, 1 = two side by side, 2 = two stacked, 3 = four in a grid. Which scope each pane shows is AlchemyScopePane0 through AlchemyScopePane3; right-click a pane to set it. + Persist + 1 + Type + S32 + Value + 0 + + AlchemyScopePane0 + + Comment + Scopes floater: which scope the first pane shows. 0 = RGB histogram, 1 = luminance, 2 = red, 3 = green, 4 = blue, 5 = vectorscope, 6 = waveform luminance, 7 = waveform RGB, 8 = parade. This is the pane a single-pane layout shows. + Persist + 1 + Type + S32 + Value + 0 + + AlchemyScopePane1 + + Comment + Scopes floater: which scope the second pane shows. Same values as AlchemyScopePane0. Used by the two-pane and four-pane layouts. + Persist + 1 + Type + S32 + Value + 7 + + AlchemyScopePane2 + + Comment + Scopes floater: which scope the third pane shows. Same values as AlchemyScopePane0. Used by the four-pane layout only. + Persist + 1 + Type + S32 + Value + 8 + + AlchemyScopePane3 + + Comment + Scopes floater: which scope the fourth pane shows. Same values as AlchemyScopePane0. Used by the four-pane layout only. + Persist + 1 + Type + S32 + Value + 5 + AlchemyScopeLogScale Comment diff --git a/indra/newview/skins/default/xui/en/floater_scopes.xml b/indra/newview/skins/default/xui/en/floater_scopes.xml index 84eec4f0f1..4d13844ae3 100644 --- a/indra/newview/skins/default/xui/en/floater_scopes.xml +++ b/indra/newview/skins/default/xui/en/floater_scopes.xml @@ -1,7 +1,7 @@ Crushed [LOW]% Blown [HIGH]% R [R] G [G] B [B] + + RGB + Luminance + Red + Green + Blue + Vectorscope + Waveform + Waveform RGB + Parade + name="scope_layout_label" + value="Layout" /> + name="scope_layout" + control_name="AlchemyScopeLayout" + tool_tip="How many scopes to show at once. Right-click any pane to choose which scope it shows: a histogram says how much of the frame sits at each brightness, a waveform or parade says where in the frame it is, and a vectorscope says what colour, on the same plane the colour wheels edit. Showing several costs nothing to measure -- every scope is computed from the same sample whether or not it is on screen."> - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2548c0f551ca8a2b75589ae2e9b8ae7f9c405932 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:48:57 -0500 Subject: [PATCH 23/32] Open the Scopes window from the Lightbox's own top bar Grading and measuring belong together, and the only way to reach the scopes was the Advanced menu. A bar-graph icon at the end of the top bar opens them instead, set apart by the same twelve pixels that separate the Look buttons from the history pair, because it is a third kind of thing again: those act on this floater, this opens another window. Command_Stats_Icon is the viewer's existing Statistics glyph, already 18px like every other overlay in that bar, and it reads as what the window opens with -- the first pane is a histogram. The bar ends at 342px of the 412 it has at min_width, so it still fits with room to spare. Floater.Toggle, not Floater.ToggleOrBringToFront. The latter is written for toolbar buttons: it closes its target only after falling through `else if (!instance->isFrontmost())`, and pressing a button inside a floater makes that floater frontmost, so from here the close branch is unreachable and the button could only ever open and raise. Both are global commit callbacks in llui.cpp, so none of this needs C++. Also records why a rebuild does not restage an XUI-only edit: the copy is a POST_BUILD command on the viewer binary, so with no C++ changed nothing relinks and nothing is copied. That one cost a debugging round here. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 30 ++++++++++++++++--- .../xui/en/floater_lightbox_settings.xml | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index ff88bc271b..542a53c017 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -338,6 +338,14 @@ enum, so a mode added without one is reachable only by editing the setting. Keep `MODE_COUNT` last: `getPaneMode` clamps against it, and that clamp is what stops a stale setting from indexing off the end. +**A button that opens another floater wants `Floater.Toggle`.** Not +`Floater.ToggleOrBringToFront`, which is written for toolbar buttons: it closes +its target only after falling through `else if (!instance->isFrontmost())`, and +pressing a button inside a floater makes *that* floater frontmost, so the close +branch is unreachable. The button then opens and raises its target and can never +shut it. Both are global commit callbacks registered in `llui.cpp`, so such a +button needs no C++ at all. + **Spawn an `LLContextMenu` with `show()`, never `LLMenuGL::showPopup()`.** `LLContextMenu` overrides `setVisible` to ignore everything except `false`: @@ -600,7 +608,21 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. - If you added a print effect: take a snapshot with "No post-processing" ticked and confirm the effect is absent from the saved file, not just from the preview. -- **Developer-build staging trap:** non-package builds do not restage XUI or - `app_settings` next to the executable. After editing, copy the changed files - into `build-.../newview//skins/...` and `.../app_settings/...` or - the viewer keeps loading the stale copies. +- **Developer-build staging trap:** non-package builds do not reliably restage + XUI or `app_settings` next to the executable, and the rule is worth knowing + rather than guessing at, because a stale copy looks exactly like an edit that + did not work. + + The copy is a `POST_BUILD` custom command on the **viewer binary target** + (`viewer_manifest.py --actions=copy`, `newview/CMakeLists.txt`). So: + + - **Edited an existing XUI/settings file and nothing else?** No C++ changed, + so the exe does not relink, so `POST_BUILD` never runs and the staged copy + stays stale *however many times you build*. Copy the file into + `build-.../newview//skins/...` or `.../app_settings/...` yourself. + - **Added a new file?** The manifest's file list is built from globs at + **configure** time, so it is not staged at all until you re-run CMake — + a rebuild alone will not find it. + - Editing XUI alongside C++ hides both cases, because the relink drags the + copy along with it. That is why this bites on the one change that happened + to be XML-only. diff --git a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml index a8a7b0a0ab..fd833ac364 100644 --- a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml +++ b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml @@ -136,6 +136,36 @@ + + Date: Tue, 11 Aug 2026 01:44:40 -0500 Subject: [PATCH 24/32] Put the colour grading skips on the headers of the sections they skip An accordion_tab can now carry a header_check_box: an interactive checkbox at the right end of its header, for a section that can be switched off without being collapsed. It is a full check_box params block, so control_name, enabled_control, tool_tip and commit_callback all behave as they do anywhere else. Omitting it leaves no checkbox at all rather than a hidden one, and every path that touches it is guarded on the pointer, so the twenty-odd accordions already in the viewer are untouched. Two things in LLAccordionCtrlTab stand in the way of anything living in a header, and both are now excepted rather than changed. handleMouseDown claims the whole header band for expand/collapse before offering the press to a child, so a control there is unreachable; handleToolTip does the same and forwards to the header without converting coordinates, so a header child's tooltip is never found once the tab is expanded. The exemptions ask first, and only for the checkbox's own rect. The mouse-down one falls through when the press is refused, because LLCheckBoxCtrl hit-tests against its bounding rect -- the box and label, not the full control -- and a hard return would leave a dead ring of pixels around the box. The five bypass checkboxes move out of the Color Grading section and onto Basic, Primaries, Split Toning, 3D LUT and Tone Curve. Ticked is the section switched on, which is the only way a box beside a section title reads, so onToggleSection inverts before setting a bit that suppresses. They gain enabled_control="RenderColorGrade", which the old row never had, and still carry no control_name: every grading setting is on the Looks whitelist, so a comparison built out of one would dirty the active Look. The doc had argued for keeping them together, on the grounds that A/B work means flipping between them and hunting through collapsed accordions is worse. That was wrong on its own terms: a collapsed accordion still shows its header, so a header checkbox is visible in every state a section has, all five line up when the sections are shut, and each one is now beside the controls it suppresses instead of a scroll away. Also corrects what this file says about the staging trap. A build does not stage XUI unreliably, it never stages it: the manifest's skins block is behind is_packaging_viewer(), which is false for --actions=copy, so the relink has nothing to do with it. That mistake cost a debugging round each of the three times it was believed. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 117 +++++++++++--- indra/llui/llaccordionctrltab.cpp | 140 +++++++++++++++- indra/llui/llaccordionctrltab.h | 28 ++++ indra/newview/alfloaterlightbox.cpp | 17 +- indra/newview/alfloaterlightbox.h | 10 +- .../default/xui/en/panel_lightbox_look.xml | 153 ++++++++---------- 6 files changed, 348 insertions(+), 117 deletions(-) diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 542a53c017..2b569ee2e2 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -134,6 +134,54 @@ persist — declare everything you expose). The essentials section's Reset All resets the Advanced sibling too (the walker includes `sec__adv`); the sibling's own button uses `parameter="sec_myfx_adv"`. +### 3b. A switch on the section header (optional) + +`accordion_tab` takes an optional `header_check_box`, drawn at the right end of +the header. It is a full `check_box` params block, so `control_name`, +`enabled_control`, `tool_tip` and `commit_callback` all behave as they do +anywhere else: + +```xml + + + + + +``` + +Note `` and not ``: inside a nested +params block the dotted prefix has to be the *block's* name, so plain +`` is what resolves. `` works +too, and `` is silently ignored. + +Omit the block and there is no checkbox — not a hidden one, none at all — which +is what keeps every other accordion in the viewer exactly as it was. Four things +worth knowing before you use it elsewhere: + +- **Size.** It defaults to a bare 24×16 box. A header checkbox carrying a + `label` has to state its own `width`, because nothing measures the text for + you. The title ellipses at the checkbox's left edge either way. The area that + actually takes the click is `LLCheckBoxCtrl`'s bounding rect — the box and + label, not the full rect — so a press inside the rect can still be refused; + the tab falls through to expand/collapse when that happens rather than + leaving a dead ring of pixels. +- **The tab eats mouse-downs.** `LLAccordionCtrlTab::handleMouseDown` claims the + whole header band and toggles expand/collapse *before* any child is offered + the press, so a control living there is unreachable by default. + `pointInHeaderCheckBox` is the exemption; anything else you add to a header + needs the same treatment. +- **The tab eats tooltips too**, and worse: `handleToolTip` forwards the header + band to `mHeader` without converting coordinates, so a header child's own + tooltip is never found once the tab is expanded. Same exemption, and it does + convert. +- **No `control_name` for a comparison switch.** See the bypass note in + *Architecture in brief*: a whitelisted setting toggled to compare dirties the + active Look. + ### 4. Rows First row uses `top="8"`; later rows chain with `top_pad`. Every value row gets @@ -260,8 +308,10 @@ Three controls act on something other than a setting. fire while a text field has focus too. Bind it to a function key or a mouse button; a letter would flash the grade every time that letter is typed. - **Per-section bypass.** `LLPipeline::sGradeBypassMask`, one bit per group, - driven by the `LightBox.ToggleBypass` checkboxes in the Color Grading section. - A set bit makes `colorCorrect` upload that group's **identity** values instead + driven by the `LightBox.ToggleSection` checkbox on each section's accordion + header. Ticked is the section switched **on**, because that is the only way a + box beside a section title reads; `onToggleSection` inverts, since the bit it + drives suppresses. A set bit makes `colorCorrect` upload that group's **identity** values instead of its settings, which lands in the early-out the shader already has for that step — so this needed no new uniform, no new variant and no recompile, and a bypassed section costs slightly *less* than an active one. If you add a @@ -286,10 +336,24 @@ Three controls act on something other than a setting. geometrically is worse than none. The groups match Reset All's grouping (`sec_` plus `sec__adv` - together), so there is only one idea of "a section" to learn. The toggles sit - together rather than one per section header, because A/B work means flipping - between them and hunting through collapsed accordions to do that is worse than - a row in the one section that is about the chain as a whole. + together), so there is only one idea of "a section" to learn — which is why + Basic - Advanced has no checkbox of its own: it is the tail of Basic, and + Basic's box switches it too. + + These lived as a row of five in the Color Grading section until it turned out + that the objection to putting them on the headers — that A/B work means + flipping between them, and hunting through collapsed accordions is worse than + one row — was answered by the header itself. A collapsed accordion still shows + its header, so a header checkbox is visible in every state the section has, + and it is beside the controls it suppresses instead of a scroll away from + them. All five headers are in view at once whenever their sections are shut, + which is the state A/B work is done in anyway. + + They carry no `control_name` on purpose. Every grading setting is on the Looks + whitelist, so a comparison built out of one would dirty the active Look and + could then be saved mid-comparison; and since the floater is destroyed on + close (see below), a fresh one comes back with all five ticked, which is what + makes "clears when the Lightbox closes" true without any code to do it. The first two reach across a frame or a click; the third outlives the floater outright. **Anything deferred like that must capture an `LLHandle`, never @@ -608,21 +672,26 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. - If you added a print effect: take a snapshot with "No post-processing" ticked and confirm the effect is absent from the saved file, not just from the preview. -- **Developer-build staging trap:** non-package builds do not reliably restage - XUI or `app_settings` next to the executable, and the rule is worth knowing - rather than guessing at, because a stale copy looks exactly like an edit that - did not work. - - The copy is a `POST_BUILD` custom command on the **viewer binary target** - (`viewer_manifest.py --actions=copy`, `newview/CMakeLists.txt`). So: - - - **Edited an existing XUI/settings file and nothing else?** No C++ changed, - so the exe does not relink, so `POST_BUILD` never runs and the staged copy - stays stale *however many times you build*. Copy the file into - `build-.../newview//skins/...` or `.../app_settings/...` yourself. - - **Added a new file?** The manifest's file list is built from globs at - **configure** time, so it is not staged at all until you re-run CMake — - a rebuild alone will not find it. - - Editing XUI alongside C++ hides both cases, because the relink drags the - copy along with it. That is why this bites on the one change that happened - to be XML-only. +- **Developer-build staging trap: a build never stages XUI at all.** Not + "unreliably" — never. This is worth knowing rather than guessing at, because a + stale copy looks exactly like an edit that did not work. + + The `POST_BUILD` custom command on the viewer binary target does run + (`viewer_manifest.py --actions=copy`, `newview/CMakeLists.txt`), but the + manifest's entire `skins` / `app_settings` / `character` / `fonts` block sits + behind `if self.is_packaging_viewer():`, which is `'package' in actions` — and + the build passes `--actions=copy`. So the copy stage refreshes the exe, the + DLLs and the plugins, and nothing else. + + - Relinking does **not** help. Neither does changing C++ alongside the XML, + neither does re-running CMake, and it makes no difference whether the file + is new or existing. + - Copy changed files into `build-.../newview//skins/...` yourself, and + check the result by **hash**: the staged tree has files of many different + ages, so a timestamp tells you nothing. + - To find drift across the whole tree, walk `indra/newview/skins/**/*.xml` and + compare each against its counterpart under + `build-.../newview//skins/`. + + An earlier revision of this file blamed the relink. It was wrong, and it cost + a debugging round each of the three times it was believed. diff --git a/indra/llui/llaccordionctrltab.cpp b/indra/llui/llaccordionctrltab.cpp index b642566f6c..1a98cf4294 100644 --- a/indra/llui/llaccordionctrltab.cpp +++ b/indra/llui/llaccordionctrltab.cpp @@ -46,6 +46,19 @@ static const F32 AUTO_OPEN_TIME = 1.f; static const S32 VERTICAL_MULTIPLE = 16; static const S32 PARENT_BORDER_MARGIN = 5; +// Optional header checkbox. It sits at the right end because the left is +// already spoken for by the expand arrow and the title, and because the right +// end is the one place a column of them lines up down an accordion. +static const S32 HEADER_CHECKBOX_RIGHT_PAD = 6; +// Between the checkbox and the title, which ellipses rather than run under it. +static const S32 HEADER_CHECKBOX_TEXT_GAP = 6; +// Default size, used when the XUI gives no width or height. Wide enough for +// the 13px box and the slack LLCheckBoxCtrl puts around it, and no wider, +// since a bare checkbox is what a header wants; anything with a label has to +// state its own width. +static const S32 HEADER_CHECKBOX_WIDTH = 24; +static const S32 HEADER_CHECKBOX_HEIGHT = 16; + static LLDefaultChildRegistry::Register t1("accordion_tab"); class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl @@ -77,6 +90,12 @@ class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl void setSelected(bool is_selected) { mIsSelected = is_selected; } + // Adopt the checkbox the tab built. Kept null otherwise, and every use of + // it in this file is guarded, so a header without one behaves exactly as + // it did before there was such a thing. + void setHeaderCheckBox(LLCheckBoxCtrl* checkbox); + LLCheckBoxCtrl* getHeaderCheckBox() const { return mHeaderCheckBox; } + virtual void onMouseEnter(S32 x, S32 y, MASK mask); virtual void onMouseLeave(S32 x, S32 y, MASK mask); virtual bool handleKey(KEY key, MASK mask, bool called_from_parent); @@ -88,6 +107,7 @@ class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl private: LLTextBox* mHeaderTextbox; + LLCheckBoxCtrl* mHeaderCheckBox = nullptr; // Overlay images (arrows) LLPointer mImageCollapsed; @@ -196,6 +216,16 @@ void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::setTitleColor(LLUIColor color } } +void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::setHeaderCheckBox(LLCheckBoxCtrl* checkbox) +{ + mHeaderCheckBox = checkbox; + addChild(checkbox); + + // Where it goes is decided in reshape, which has the header's size; this + // just makes sure it is somewhere reasonable if reshape has already run. + reshape(getRect().getWidth(), getRect().getHeight()); +} + void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::draw() { S32 width = getRect().getWidth(); @@ -247,10 +277,39 @@ void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::draw() void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::reshape(S32 width, S32 height, bool called_from_parent /* = true */) { + // This override never chains to LLUICtrl::reshape, so follows flags do + // nothing to header children and everything in here is positioned by + // hand. The checkbox is placed first because the title has to stop short + // of it; without one, text_right stays the full width and the rest of + // this function is what it always was. + S32 text_right = width; + if (mHeaderCheckBox) + { + const LLRect& old_check_rect = mHeaderCheckBox->getRect(); + LLRect check_rect; + check_rect.setLeftTopAndSize(width - HEADER_CHECKBOX_RIGHT_PAD - old_check_rect.getWidth(), + (height + old_check_rect.getHeight()) / 2, + old_check_rect.getWidth(), + old_check_rect.getHeight()); + if (check_rect != old_check_rect) + { + // setRect rather than reshape: the size is not changing, only the + // position, and LLCheckBoxCtrl::reshape does nothing when the + // size is unchanged. The bounding rect is what the mouse is + // tested against, so it has to be told the control moved. + mHeaderCheckBox->setRect(check_rect); + mHeaderCheckBox->updateBoundingRect(); + } + + // A header narrow enough for these to cross would otherwise hand the + // textbox an inside-out rect. + text_right = llmax(check_rect.mLeft - HEADER_CHECKBOX_TEXT_GAP, HEADER_TEXT_LEFT_OFFSET); + } + S32 header_height = mHeaderTextbox->getTextPixelHeight(); LLRect old_header_rect = mHeaderTextbox->getRect(); - LLRect textboxRect(HEADER_TEXT_LEFT_OFFSET, (height + header_height) / 2, width, (height - header_height) / 2); + LLRect textboxRect(HEADER_TEXT_LEFT_OFFSET, (height + header_height) / 2, text_right, (height - header_height) / 2); if (old_header_rect.getHeight() != textboxRect.getHeight() || old_header_rect.mLeft != textboxRect.mLeft || old_header_rect.mTop != textboxRect.mTop @@ -351,6 +410,7 @@ LLAccordionCtrlTab::Params::Params() ,header_image_pressed("header_image_pressed") ,header_image_focused("header_image_focused") ,header_text_color("header_text_color") + ,header_check_box("header_check_box") ,fit_panel("fit_panel",true) ,selection_enabled("selection_enabled", false) { @@ -373,6 +433,7 @@ LLAccordionCtrlTab::LLAccordionCtrlTab(const LLAccordionCtrlTab::Params&p) ,mSelectionEnabled(p.selection_enabled) ,mContainerPanel(NULL) ,mScrollbar(NULL) + ,mHeaderCheckBox(NULL) { mStoredOpenCloseState = false; mWasStateStored = false; @@ -385,6 +446,36 @@ LLAccordionCtrlTab::LLAccordionCtrlTab(const LLAccordionCtrlTab::Params&p) mHeader = LLUICtrlFactory::create(headerParams); addChild(mHeader, 1); + if (p.header_check_box.isProvided()) + { + LLCheckBoxCtrl::Params checkParams = p.header_check_box; + + // The header decides where this goes on every reshape, so keep + // follows out of it: an anchored child would be dragged somewhere + // else first and land back here, which is at best wasted work. + checkParams.follows.flags(FOLLOWS_NONE); + + // A checkbox is normally sized by the XUI that places it, and this + // one is placed by us. Default it to the bare box; anything with a + // label is the caller's to size, and saying so is the whole reason + // this reads the params rather than always overriding them. + if (!checkParams.rect.width.isProvided()) + { + checkParams.rect.width = HEADER_CHECKBOX_WIDTH; + } + if (!checkParams.rect.height.isProvided()) + { + checkParams.rect.height = HEADER_CHECKBOX_HEIGHT; + } + + // Built here rather than in the header because create() resolves + // control_name and commit_callback against the registrar scope that + // is open now -- the floater being read from XUI -- and the header + // is only where it ends up living. + mHeaderCheckBox = LLUICtrlFactory::create(checkParams); + mHeader->setHeaderCheckBox(mHeaderCheckBox); + } + LLFocusableElement::setFocusReceivedCallback(boost::bind(&LLAccordionCtrlTab::selectOnFocusReceived, this)); if (!p.selection_enabled) @@ -524,8 +615,40 @@ void LLAccordionCtrlTab::onUpdateScrollToChild(const LLUICtrl *cntrl) LLUICtrl::onUpdateScrollToChild(cntrl); } +// Where the header checkbox is, in this tab's own coordinates, so the two +// header-band branches below can ask whether a click or a hover is really +// meant for it. Returns false when there is no checkbox, which is the answer +// for every tab that did not ask for one. +bool LLAccordionCtrlTab::pointInHeaderCheckBox(S32 x, S32 y) const +{ + if (!mHeaderCheckBox || !mHeaderCheckBox->getVisible()) + { + return false; + } + + LLRect check_rect; + mHeaderCheckBox->localRectToOtherView(mHeaderCheckBox->getLocalRect(), &check_rect, this); + return check_rect.pointInRect(x, y); +} + bool LLAccordionCtrlTab::handleMouseDown(S32 x, S32 y, MASK mask) { + // The branch below claims the whole header band and opens or closes the + // tab, so a checkbox living in that band would never be clicked. Send + // this one press down the normal child path instead, which reaches the + // header and then the checkbox inside it. + // + // Only when it is taken, though: LLCheckBoxCtrl hit-tests against its + // bounding rect, which is the box and label rather than the whole + // control, so a press can land inside the rect and still be refused. Left + // at "return", that inset would be a ring of pixels around the checkbox + // where clicking did nothing at all; falling through means it does what + // the rest of the header does. + if (pointInHeaderCheckBox(x, y) && LLUICtrl::handleMouseDown(x, y, mask)) + { + return true; + } + if (mCollapsible && mHeaderVisible && mCanOpenClose) { if (y >= (getRect().getHeight() - HEADER_HEIGHT)) @@ -1139,6 +1262,21 @@ void LLAccordionCtrlTab::ctrlSetLeftTopAndSize(LLView* panel, S32 left, S32 top, bool LLAccordionCtrlTab::handleToolTip(S32 x, S32 y, MASK mask) { + // Same reason as handleMouseDown: the header branch answers for the whole + // band, and it answers with the title's tooltip. A header checkbox is a + // separate control saying a separate thing, so let it speak for its own + // rect. Its coordinates have to be converted -- the header branch below + // passes this tab's, which is why its own children never match. + if (pointInHeaderCheckBox(x, y)) + { + LLRect check_rect; + mHeaderCheckBox->localRectToOtherView(mHeaderCheckBox->getLocalRect(), &check_rect, this); + if (mHeaderCheckBox->handleToolTip(x - check_rect.mLeft, y - check_rect.mBottom, mask)) + { + return true; + } + } + //header may be not the first child but we need to process it first if (y >= (getRect().getHeight() - HEADER_HEIGHT - HEADER_HEIGHT / 2)) { diff --git a/indra/llui/llaccordionctrltab.h b/indra/llui/llaccordionctrltab.h index 419a995e7f..edc22b4607 100644 --- a/indra/llui/llaccordionctrltab.h +++ b/indra/llui/llaccordionctrltab.h @@ -29,6 +29,7 @@ #include #include "llrect.h" +#include "llcheckboxctrl.h" #include "lluictrl.h" #include "lluicolor.h" #include "llstyle.h" @@ -81,6 +82,19 @@ class LLAccordionCtrlTab : public LLUICtrl Optional header_visible; + // An interactive checkbox at the right end of the header, for a + // section that can be switched off without being collapsed. Omitted + // by every tab that does not want one, and there is no checkbox at + // all in that case -- not a hidden one -- so the tabs already in the + // viewer keep the header they have. + // + // A full check_box params block, so control_name, enabled_control, + // tool_tip and commit_callback all work as they do anywhere else. + // Sizing is the one thing it does not take from the block: an + // unsized checkbox gets a default square wide enough for the box, + // and a header checkbox that carries a label has to say how wide. + Optional header_check_box; + Optional fit_panel; Optional selection_enabled; @@ -134,6 +148,12 @@ class LLAccordionCtrlTab : public LLUICtrl void canOpenClose(bool can_open_close) { mCanOpenClose = can_open_close; }; bool canOpenClose() const { return mCanOpenClose; }; + // The header checkbox, or null for a tab that did not ask for one -- + // which is all of them unless header_check_box was given. Callers that + // want the value should reach it through this rather than getChild: the + // checkbox lives inside the header, not in the tab's own child list. + LLCheckBoxCtrl* getHeaderCheckBox() const { return mHeaderCheckBox; } + virtual bool postBuild(); S32 notifyParent(const LLSD& info); @@ -219,11 +239,19 @@ class LLAccordionCtrlTab : public LLUICtrl void selectOnFocusReceived(); void deselectOnFocusLost(); + // Whether (x, y), in this tab's coordinates, lands on the header + // checkbox. False whenever there is no checkbox. + bool pointInHeaderCheckBox(S32 x, S32 y) const; + private: class LLAccordionCtrlTabHeader; LLAccordionCtrlTabHeader* mHeader; //Header + // Owned by the header, which is where it is drawn and where its rect is + // kept; held here too because the tab is what the mouse reaches first. + LLCheckBoxCtrl* mHeaderCheckBox; + bool mDisplayChildren; //Expanded/collapsed bool mCollapsible; bool mHeaderVisible; diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 13c3ac5ca3..b5f35538d0 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -151,7 +151,7 @@ ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) { mCommitCallbackRegistrar.add("LightBox.ResetControlDefault", std::bind(&ALFloaterLightBox::onClickResetControlDefault, this, std::placeholders::_2)); mCommitCallbackRegistrar.add("LightBox.ResetSection", std::bind(&ALFloaterLightBox::onClickResetSection, this, std::placeholders::_2)); - mCommitCallbackRegistrar.add("LightBox.ToggleBypass", std::bind(&ALFloaterLightBox::onToggleBypass, this, std::placeholders::_1, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ToggleSection", std::bind(&ALFloaterLightBox::onToggleSection, this, std::placeholders::_1, std::placeholders::_2)); mCommitCallbackRegistrar.add("LightBox.ReferenceGrab", std::bind(&ALFloaterLightBox::onClickReferenceGrab, this)); mCommitCallbackRegistrar.add("LightBox.ReferenceClear", std::bind(&ALFloaterLightBox::onClickReferenceClear, this)); // Lambdas rather than bind: applyHistory answers whether it did anything, @@ -351,12 +351,13 @@ void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) } } -void ALFloaterLightBox::onToggleBypass(LLUICtrl* ctrl, const LLSD& userdata) +void ALFloaterLightBox::onToggleSection(LLUICtrl* ctrl, const LLSD& userdata) { // Section id to bit. The grouping matches Reset All's, which walks // sec_ and sec__adv together -- so "basic" covers the // Basic-Advanced rows too, and the user only has to learn one idea of what - // a section is. + // a section is. Which is also why the Basic-Advanced header carries no + // checkbox of its own: it is not a section, it is the tail of one. static const std::map bypass_bits = { { "basic", LLPipeline::GRADE_BYPASS_BASIC }, { "primaries", LLPipeline::GRADE_BYPASS_PRIMARIES }, @@ -371,16 +372,22 @@ void ALFloaterLightBox::onToggleBypass(LLUICtrl* ctrl, const LLSD& userdata) return; } + // Ticked is the section switched on, so the bit -- which suppresses -- + // is set when the box is clear. + // // Nothing is written to gSavedSettings here, deliberately: every grading // control is on the Looks whitelist, so a comparison built out of one // would mark the active Look dirty and could then be saved mid-comparison. + // That is also why the checkboxes have no control_name: they are worth + // exactly one viewing session, and the floater is destroyed when it + // closes, which is what puts them all back on. if (ctrl->getValue().asBoolean()) { - LLPipeline::sGradeBypassMask |= found->second; + LLPipeline::sGradeBypassMask &= ~found->second; } else { - LLPipeline::sGradeBypassMask &= ~found->second; + LLPipeline::sGradeBypassMask |= found->second; } } diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 9a505eb257..6f59963a72 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -67,9 +67,13 @@ class ALFloaterLightBox final : public LLFloater private: void onClickResetControlDefault(const LLSD& userdata); void onClickResetSection(const LLSD& userdata); - /// Set or clear one section's bit in LLPipeline::sGradeBypassMask. The - /// settings are never touched, so a comparison cannot dirty the Look. - void onToggleBypass(LLUICtrl* ctrl, const LLSD& userdata); + /// Set or clear one section's bit in LLPipeline::sGradeBypassMask, from + /// the checkbox on that section's accordion header. Ticked means the + /// section is applied, which is the only way a checkbox beside a section + /// title reads; the bit it drives is a bypass bit, so the sense inverts + /// here. The settings are never touched, so a comparison cannot dirty + /// the Look. + void onToggleSection(LLUICtrl* ctrl, const LLSD& userdata); void onCommitVec3(LLUICtrl* ctrl); void refreshVec3Row(const std::string& setting_name); void setupToneCurve(); diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index 183c128639..bcace01edf 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -523,14 +523,14 @@ Row conventions: + - - - - - - - - - - - - - - - + right="-8" + height="30" + word_wrap="true" + font="SansSerifSmall" + name="grading_bypass_note" + value="Compare: untick the checkbox on a section's header to switch that section off while you look. Nothing is saved and the Look is not marked as changed." /> @@ -666,7 +602,7 @@ Row conventions: height="18" width="64" left="76" - top="170" + top="144" label="Clear" name="reference_clear" tool_tip="Discard the reference still and return to the live image"> @@ -677,7 +613,7 @@ Row conventions: follows="left|top" layout="topleft" left="148" - top="169" + top="143" width="130" height="20" name="reference_mode" @@ -700,7 +636,7 @@ Row conventions: follows="left|top|right" layout="topleft" left="8" - top="194" + top="168" right="-8" height="16" label="Wipe position" @@ -720,7 +656,7 @@ Row conventions: height="18" width="100" right="-8" - top="218" + top="192" label="Reset All" halign="left" scale_image="true" @@ -741,6 +677,19 @@ Row conventions: name="atab_sec_basic" title="Basic" fit_panel="true"> + + + + + + + + + + + + + + + + Date: Tue, 11 Aug 2026 02:11:14 -0500 Subject: [PATCH 25/32] Fold Basic's advanced tail in, and lift the grading master onto its header Basic - Advanced held two sliders, Brightness and Hue shift, which is what this file's own rule already calls a fold rather than a split: "fold instead of splitting when the advanced tail is small (~2-3 rows)". They move into Basic ahead of its Reset All and the sibling tab goes. Nothing in C++ needed changing -- onClickResetSection looks for sec__adv and simply does not find one -- and Exposure & Tone keeps its own Advanced sibling, so the convention stands where it earns its keep. The Color Grading section had a worse problem than sparseness. It held the master switch for the entire grading suite and the reference still, and it was closed by default, so the two things you most want at hand were the two things you had to go and open a section to reach. RenderColorGrade now rides that accordion's own header as a header_check_box, which is legible and throwable whether or not the section is open, and reads the same way as the five section switches below it. What tells the two kinds apart is worth knowing: the master carries control_name because it is a setting, and the section switches carry none because they are a viewing state that must not dirty the active Look. With the master gone from the body, the section opens by default, and what opens is now worth the space it takes. Two paragraphs of prose became one two-line note; the linear-light explanation they carried is still there, in its tooltip, where you go when you want to look it up rather than every time you glance at the tab. The section went from 218 to 140, so it now costs less open than it used to cost shut and then opened. Moving the master off the panel would have quietly broken that section's Reset All, since collectBoundControls walks the panel and the header is not in it. onClickResetSection now also checks atab_
's header checkbox, walking the control rather than reading it directly, because LLCheckBoxCtrl hands control_name down to its button and the binding sits on the child. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 22 +++- indra/newview/alfloaterlightbox.cpp | 20 ++- indra/newview/pipeline.h | 2 +- .../default/xui/en/panel_lightbox_look.xml | 117 +++++------------- 4 files changed, 67 insertions(+), 94 deletions(-) diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 2b569ee2e2..77eeaa3f67 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -180,7 +180,10 @@ worth knowing before you use it elsewhere: convert. - **No `control_name` for a comparison switch.** See the bypass note in *Architecture in brief*: a whitelisted setting toggled to compare dirties the - active Look. + active Look. A header checkbox that *is* a setting — the grading master — + takes `control_name` as normal, and `LightBox.ResetSection` reaches it: the + walker checks `atab_
`'s header checkbox as well as the panel, so a + bound switch on a header is still covered by that section's Reset All. ### 4. Rows @@ -336,9 +339,9 @@ Three controls act on something other than a setting. geometrically is worse than none. The groups match Reset All's grouping (`sec_` plus `sec__adv` - together), so there is only one idea of "a section" to learn — which is why - Basic - Advanced has no checkbox of its own: it is the tail of Basic, and - Basic's box switches it too. + together), so a section with a long tail in an Advanced sibling is one switch + and one idea of "a section". Put the checkbox on the essentials tab only; the + Advanced sibling is the tail of a section, not a section. These lived as a row of five in the Color Grading section until it turned out that the objection to putting them on the headers — that A/B work means @@ -584,8 +587,15 @@ only; apply per row, not on the parent panel. Reference patterns: `disabled_control="RenderHDREnabled"` — greying, not visibility, so the layout never gets holes and both modes stay discoverable. - **`RenderColorGrade` is the master switch for the entire grading suite** - (LUT *and* Basic Grade, White Balance, Split Toning, Lift/Gamma/Gain, Tone - Curve). Any new grading control must gate on it or it will look inert. + (LUT *and* Basic, White Balance, Split Toning, Lift/Gamma/Gain, Tone Curve). + Any new grading control must gate on it or it will look inert. It lives on + the Color Grading accordion's own header, as a `header_check_box` with + `control_name` — so it is legible and throwable whether or not the section is + open, and it reads the same way as the five section switches beneath it. Note + what tells the two kinds apart: the master has a `control_name` because it is + a setting, the section switches have none because they are a viewing state. + That section is also the only one that opens by default, which is why its + body is kept to one line of text plus the reference still. - Int-selected modes can't gate declaratively; either leave rows enabled with a "(X only)" tooltip or add a small signal handler like `updateTonemapperRows()`. diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index b5f35538d0..ea6cfe2c39 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -31,6 +31,7 @@ #include "llviewerprecompiledheaders.h" #include "alfloaterlightbox.h" +#include "llaccordionctrltab.h" #include "llcombobox.h" #include "llfloaterreg.h" #include "alcurveeditorctrl.h" @@ -339,6 +340,19 @@ void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) collectBoundControls(advp, keys); } + // A section's own header can carry a bound checkbox -- Color Grading's + // master switch does -- and that is part of the section however it is + // drawn. It is not in the panel, so nothing above would find it. Walk the + // control rather than reading it directly: LLCheckBoxCtrl hands + // control_name down to its button, so the binding is on the child. + if (auto* tabp = findChild("atab_" + section)) + { + if (LLCheckBoxCtrl* checkp = tabp->getHeaderCheckBox()) + { + collectBoundControls(checkp, keys); + } + } + // One thing the user did, however many controls it moves: undoing a Reset // All eleven times would be absurd. ScopedHistoryGroup group(mHistory); @@ -354,10 +368,8 @@ void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) void ALFloaterLightBox::onToggleSection(LLUICtrl* ctrl, const LLSD& userdata) { // Section id to bit. The grouping matches Reset All's, which walks - // sec_ and sec__adv together -- so "basic" covers the - // Basic-Advanced rows too, and the user only has to learn one idea of what - // a section is. Which is also why the Basic-Advanced header carries no - // checkbox of its own: it is not a section, it is the tail of one. + // sec_ and sec__adv together, so a section with a long tail in an + // Advanced sibling is one switch and one idea of what a section is. static const std::map bypass_bits = { { "basic", LLPipeline::GRADE_BYPASS_BASIC }, { "primaries", LLPipeline::GRADE_BYPASS_PRIMARIES }, diff --git a/indra/newview/pipeline.h b/indra/newview/pipeline.h index ee6402270c..b6d1af9fdd 100644 --- a/indra/newview/pipeline.h +++ b/indra/newview/pipeline.h @@ -787,7 +787,7 @@ class LLPipeline /// rest of the grade rather than against nothing at all. enum EGradeBypass : U32 { - GRADE_BYPASS_BASIC = 1 << 0, ///< White balance, tone, presence: Basic and Basic-Advanced + GRADE_BYPASS_BASIC = 1 << 0, ///< White balance, tone, presence: the Basic section GRADE_BYPASS_PRIMARIES = 1 << 1, ///< Lift / gamma / gain GRADE_BYPASS_SPLIT = 1 << 2, ///< Split toning GRADE_BYPASS_LUT = 1 << 3, ///< 3D LUT diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index bcace01edf..297f074696 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -521,63 +521,51 @@ Row conventions: + + - + - - + name="grading_order_note" + tool_tip="White Balance and Lift / Gamma / Gain act in linear light, between exposure and the tonemap curve; everything from Basic down acts on display values after it. Switching a section off saves nothing and does not mark the Look as changed, and every section comes back when the Lightbox closes." + value="The sections below run in the order the renderer applies them. Untick a section's header checkbox to switch it off while you look." /> @@ -602,7 +590,7 @@ Row conventions: height="18" width="64" left="76" - top="144" + top="66" label="Clear" name="reference_clear" tool_tip="Discard the reference still and return to the live image"> @@ -613,7 +601,7 @@ Row conventions: follows="left|top" layout="topleft" left="148" - top="143" + top="65" width="130" height="20" name="reference_mode" @@ -636,7 +624,7 @@ Row conventions: follows="left|top|right" layout="topleft" left="8" - top="168" + top="90" right="-8" height="16" label="Wipe position" @@ -656,7 +644,7 @@ Row conventions: height="18" width="100" right="-8" - top="192" + top="114" label="Reset All" halign="left" scale_image="true" @@ -673,19 +661,17 @@ Row conventions: + beside a section title reads. --> + tool_tip="Whether Basic is applied. Untick to switch the section off while you compare: nothing is saved, the Look is not marked as changed, and it comes back when the Lightbox closes."> @@ -693,7 +679,7 @@ Row conventions: - - - - - + parameter="sec_basic" /> From 8768d9a4c91a307a6bbfcf919cd31c60d595668f Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:21:41 -0500 Subject: [PATCH 26/32] Bring the Lightbox guide back in line with what v2 actually built The guide was written when v2 landed and has drifted since, in the way a document drifts when the code around it keeps moving: not wrong in outline, wrong in the details you would only find by checking. Three of those were arithmetic. "Three widgets exist beyond the standard rows" was never true -- there are two tags, and the third richer control is the header checkbox, which is a param. "Three controls act on something other than a setting" was true until the reference still made it four. And the two closing paragraphs of the per-section bypass bullet had been stranded under the reference-still bullet that was inserted between them, so a rule about which tab carries the checkbox appeared to be a rule about reference stills. The claim that around twenty other accordions exist was out by an order of magnitude: 26 files in the English skin declare 75 of them, which makes the case for an additive param rather than weakens it. The Verify section still told you to tick a bypass to switch a section off, which is backwards since the switches moved to the headers. It now says untick, and adds the two checks a header control needs: that clicking it does not expand the section, and that its tooltip appears when the section is expanded -- the second only fails in that state, so testing collapsed proves nothing. What was missing was more of it than what was wrong. The widget reference listed the params it happened to need and not the ones a reader would look up, so both widgets now carry their full set. gl_polyline_2d and gl_polyfill_2d were written for this work, are the reason the graphs and scopes look the way they do, and appeared nowhere. The top bar had never been described at all despite being the one part of the floater with a fixed width budget, so it gets a section: the budget, the grouping gap that carries its meaning, why the buttons are icons, and where to get overlays. The scopes floater's own XUI gets a paragraph, since almost none of that window is widgets and that is worth saying out loud. An inventory table up front now says what v2 added and what it altered, and the architecture table gained the four files that were missing from it. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 204 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 157 insertions(+), 47 deletions(-) diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 77eeaa3f67..91772272c3 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -5,10 +5,10 @@ so that exposing a new post-processing effect through the usual rows is (almost always) a pure XUI change: no new C++, no layout mathematics beyond the rules below, resets and live preview for free. -Richer controls — colour wheels, graphs, the eyedropper, the scopes — do have -C++ behind them, written once each and then reused from XUI like any other row. -Sections 4b to 4d cover those; if you only need sliders and checkboxes you can -skip them. +Richer controls — colour wheels, graphs, the eyedropper, the scopes, a switch on +a section header — do have C++ behind them, written once each and then reused +from XUI like any other row. Sections 3b and 4b to 4d cover those; if you only +need sliders and checkboxes you can skip them. ## Architecture in brief @@ -29,8 +29,40 @@ statics), so edits preview live with no glue code. | Colour wheel widget + its maths | `indra/newview/alcolorwheel{ctrl,model}.{h,cpp}` | | Curve/band graph widget + its maths | `indra/newview/alcurve{editorctrl,model}.{h,cpp}` | | Scopes floater + measurement | `indra/newview/alfloaterscopes.{h,cpp}`, `alscopedata.{h,cpp}` | +| Scope pane assignment menu | `.../menu_scopes_pane.xml` | +| Undo/redo stack | `indra/newview/algradehistory.{h,cpp}` | | White-balance map and its inverse | `indra/newview/alwhitebalancesolver.{h,cpp}` | | Scene colour picker tool | `indra/newview/altoolscenepicker.{h,cpp}` | +| Header checkbox on `accordion_tab` | `indra/llui/llaccordionctrltab.{h,cpp}` | +| Anti-aliased 2D polyline and fill | `indra/llrender/llrender2dutils.{h,cpp}` | + +Five of those have unit tests, and the tests are the reason the maths in them can +be trusted: `alcolorwheelmodel_test`, `alcurvemodel_test`, `algradehistory_test`, +`alscopedata_test`, `alwhitebalancesolver_test`. Anything with arithmetic in it +belongs on that list. + +### What v2 added, and what it altered + +Two of these are new XUI tags, one is a new param on a stock widget, and one is a +new pair of drawing primitives everything else is painted with. Nothing else in +the viewer's widget set was touched. + +| Thing | Written as | Where | +|---|---|---| +| Colour wheel | `` | §4b | +| Curve / band graph | `` | §4b | +| Checkbox on an accordion header | `` | §3b | +| Anti-aliased polyline and area fill | `gl_polyline_2d`, `gl_polyfill_2d` | §4b | +| Floater top bar (Looks, history, scopes) | ordinary buttons, `Floater.Toggle` | §4g | +| Scopes window | its own floater | §4d | + +`accordion_tab` is the only **stock** widget altered, and the change is additive: +a tab that does not ask for `header_check_box` gets exactly the header it always +had. That mattered rather a lot — 26 other files in the English skin alone +declare accordion tabs, 75 of them, and every one is outfit editing, profiles, +preferences or the About box. Which is the standard to hold anything else here +to: if the Lightbox needs something from a shared widget, it asks for it by an +optional param and leaves the default behaviour alone. **The C++ does not grow per effect. It does grow per new *kind of control*.** That is the real contract, and the distinction matters when you plan work: @@ -240,7 +272,9 @@ involved either way. ### 4b. The richer widgets -Three widgets exist beyond the standard rows. Each is one XUI tag. +Two widgets exist beyond the standard rows, and each is one XUI tag. (The third +richer control, the switch on a section header, is §3b — it is a param on +`accordion_tab` rather than a tag of its own.) **`color_wheel`** — a hue ring with a draggable puck, a master slider and three editable channel fields, all driving one Vector3 setting. @@ -263,6 +297,11 @@ editable channel fields, all driving one Vector3 setting. a control that does nothing. - A bank of three at 120px wide with a 4px gap fits the accordion at the floater's minimum width. Lefts 8 / 132 / 256. +- The rest of its params are appearance and have working defaults you should + need to override only for a genuinely different control: `ring_thickness`, + `ring_steps` (segments the ring is drawn in), `puck_radius`, `decimal_digits` + for the three channel fields, and `border_color` / `face_color` / + `crosshair_color`. `label` names the wheel above the ring. **`curve_editor`** — a graph with draggable handles, used for the tone curve and the split-tone bands. It owns no curve: a consumer hands it a sampling function @@ -283,15 +322,36 @@ and a handle list, which is why one widget serves both. A handle that slides horizontally to set a value locks *Y*. - `draw_diagonal="true"` draws the identity, which is meaningful for a tone curve and meaningless for anything else. +- `grid_divisions` sets the backing grid; `curve_samples` how finely the + sampling function is evaluated across the width; `handle_radius` and + `curve_width` the hit target and the stroke. Colours are `background_color`, + `border_color`, `grid_color`, `curve_color`, `handle_color`. **Graph maths belongs in the model, next to a test.** `ALCurveModel` mirrors the shader's own functions, and `alcurvemodel_test` transcribes the GLSL independently and compares. A graph that merely illustrates the shader is worse than none: it will be believed. If you plot something new, transcribe it. +**The two of them draw with `gl_polyline_2d` and `gl_polyfill_2d`** +(`llrender2dutils`), added for this work and available to anything else that +plots. Use them rather than reaching for `LLRender::setLineWidth` and +`GL_LINE_SMOOTH`: smoothing appears nowhere else in this tree, core profiles +routinely ignore it, and `setLineWidth` clamps to `mAliasedLineRange`, which is +`[1,1]` on most core drivers — so neither width nor smoothing can be relied on +from the fixed pipeline. The polyline lays a ribbon of triangles with a +one-pixel alpha falloff and mitred, clamped joins, so a curve has no notches at +its vertices and a hairpin is blunted rather than shot off to infinity. + +The fill's edge is deliberately left aliased. Pass it the translucent colour +such a fill wants and outline it separately if you need a crisp edge; a feathered +fill under a feathered outline doubles the coverage along the shared path and +draws a darker seam. + ### 4c. Tools -Three controls act on something other than a setting. +Four controls act on something other than a setting. None of them writes to +`gSavedSettings`, and that is the point of grouping them: each parks state +somewhere else, which is a liability the ordinary rows do not have. - **Eyedropper.** A `button` calling `LightBox.PickWhiteBalance` installs `ALToolScenePicker` as a transient tool. On mouse-up it asks @@ -314,29 +374,12 @@ Three controls act on something other than a setting. driven by the `LightBox.ToggleSection` checkbox on each section's accordion header. Ticked is the section switched **on**, because that is the only way a box beside a section title reads; `onToggleSection` inverts, since the bit it - drives suppresses. A set bit makes `colorCorrect` upload that group's **identity** values instead - of its settings, which lands in the early-out the shader already has for that - step — so this needed no new uniform, no new variant and no recompile, and a - bypassed section costs slightly *less* than an active one. If you add a - grading step, add its identity to that block or the bypass will quietly skip - it. -- **Reference still.** `LLPipeline::requestReferenceStill` grabs the frame about - to be presented; `RenderReferenceWipeMode` then wipes the live image against - it. Where hold-to-compare shows you *no* grade, this shows you the grade you - had ten minutes ago, which is the comparison that matters once a look has - taken more than a moment to build. - - Grabbed at the same point the scopes sample — after every post pass, before - the print effects — and substituted **before** those effects in - `blitWithEffectsF.glsl`, so vignette and grain land on both sides of the - seam. The comparison is then about the grade rather than the print - treatment. - - The mode is forced to zero unless a still exists, which is what lets the - shader sample the reference without checking. - - Both settings are `Persist=0`: a still cannot outlive the session, so a - mode that did would come back pointing at nothing. - - A resize drops the still. Sampling a still of one resolution against a - frame of another would stretch it, and a reference you cannot trust - geometrically is worse than none. + drives suppresses. A set bit makes `colorCorrect` upload that group's + **identity** values instead of its settings, which lands in the early-out the + shader already has for that step — so this needed no new uniform, no new + variant and no recompile, and a bypassed section costs slightly *less* than an + active one. If you add a grading step, add its identity to that block or the + bypass will quietly skip it. The groups match Reset All's grouping (`sec_` plus `sec__adv` together), so a section with a long tail in an Advanced sibling is one switch @@ -357,12 +400,29 @@ Three controls act on something other than a setting. could then be saved mid-comparison; and since the floater is destroyed on close (see below), a fresh one comes back with all five ticked, which is what makes "clears when the Lightbox closes" true without any code to do it. +- **Reference still.** `LLPipeline::requestReferenceStill` grabs the frame about + to be presented; `RenderReferenceWipeMode` then wipes the live image against + it. Where hold-to-compare shows you *no* grade, this shows you the grade you + had ten minutes ago, which is the comparison that matters once a look has + taken more than a moment to build. + - Grabbed at the same point the scopes sample — after every post pass, before + the print effects — and substituted **before** those effects in + `blitWithEffectsF.glsl`, so vignette and grain land on both sides of the + seam. The comparison is then about the grade rather than the print + treatment. + - The mode is forced to zero unless a still exists, which is what lets the + shader sample the reference without checking. + - Both settings are `Persist=0`: a still cannot outlive the session, so a + mode that did would come back pointing at nothing. + - A resize drops the still. Sampling a still of one resolution against a + frame of another would stretch it, and a reference you cannot trust + geometrically is worse than none. -The first two reach across a frame or a click; the third outlives the floater -outright. **Anything deferred like that must capture an `LLHandle`, never -`this`** — the Lightbox declares neither `single_instance` nor `reuse_instance`, -so closing it *destroys* it, and an armed picker holding a raw pointer is a -use-after-free with no window of luck involved. +The first two reach across a frame or a keypress; the last two park state that +outlives the floater. **Anything deferred like that must capture an `LLHandle`, +never `this`** — the Lightbox declares neither `single_instance` nor +`reuse_instance`, so closing it *destroys* it, and an armed picker holding a raw +pointer is a use-after-free with no window of luck involved. **And anything that parks state outside the floater must clear it in the destructor.** The bypass mask lives in the pipeline; left set, a closed Lightbox @@ -370,7 +430,13 @@ would leave a section suppressed with nothing on screen to say so and no setting to inspect. That is harder to diagnose than a crash. The reference still is the same rule with a second reason — it is a full-resolution target, so leaving one behind holds real memory for a comparison nobody can see or switch off any more. -`~ALFloaterLightBox` clears all three. +`~ALFloaterLightBox` clears all three: the armed picker, the bypass mask, and +the still along with its wipe mode. + +That the destructor is enough turns on the floater being destroyed on close, so +do not "tidy up" by declaring `single_instance` on it without moving this +cleanup to `onClose` first. It would keep every one of these switched on behind +a closed window. ### 4d. Scopes @@ -398,6 +464,14 @@ The draw functions therefore take `(mode, rect)` rather than reading the mode and the panel themselves. Anything new must too: a scope that reaches for `mPlotPanel->getRect()` draws over all four panes. +Its XUI is deliberately thin, because almost none of that window is widgets. A +`combo_box` bound to `AlchemyScopeLayout`, a `check_box` on +`AlchemyScopeLogScale`, an empty `panel` named `scope_plot` that the scopes are +painted into, and a `text` for the clipping readout. Everything else is drawn. +The nine pane labels are `floater.string` entries named `mode_*` rather than +literals, so they translate, and `menu_scopes_pane.xml` is a `context_menu` of +`menu_item_check` rows wired to `Scopes.SetPaneMode` / `Scopes.IsPaneMode`. + **Adding a scope is three edits, not one.** A new `EMode` needs an entry in `modeStringName`, a `floater.string` for its corner label, and an item in `menu_scopes_pane.xml` — the menu is hand-written rather than generated from the @@ -541,16 +615,40 @@ opposite reason: a global Ctrl+Z fires while the user is typing anywhere in the viewer. Being handled there also means a focused text field keeps Ctrl+Z for its own undo, since the focus chain is offered the key first. -The Undo and Redo buttons in the top bar are the visible half of that, and they -exist because **the Looks buttons were turned into icons to pay for them**: four -labels cost 192px of a bar with 412 to spend at `min_width`, the same four icons -cost 80px, and the pair fits in the change with room over (36px of slack became -92px). Icons also sidestep §5's silent clipping if this floater is ever -translated, where "Save As" becomes "Speichern unter". Both buttons, and the -reference row below, are greyed from `draw()` rather than from a signal — the -undo stack moves on every commit, every undo and every Look apply, and hanging a +The Undo and Redo buttons in the top bar are the visible half of that. Both, and +the reference row, are greyed from `draw()` rather than from a signal — the undo +stack moves on every commit, every undo and every Look apply, and hanging a refresh off each of those is more places to forget than a polled compare costs. +### 4g. The top bar + +`lightbox_topbar` in `floater_lightbox_settings.xml` is an ordinary `panel` of +ordinary widgets, and worth reading before you add to it, because it is the one +part of this floater with a fixed width budget: **412px at `min_width`**, of +which it currently spends 342. + +Left to right: the Looks `combo_box`, then Save / Save As / Delete / Revert, +then Undo / Redo, then Scopes. Three groups, separated by 12px where the +adjacent buttons inside a group are separated by 4. **That gap is the only thing +that says they are different kinds of thing** — the first group acts on the +Look, the second on the grade's own history, the third opens another window — so +keep it if you add a fourth kind, and use 4px if you are extending a group. + +Everything after the combo is an **18px icon with an empty label**, and the +tooltip carries the name. That is not decoration. Four text labels cost 192px of +the 412; the same four icons cost 80. It also sidesteps §5's silent clipping the +day this floater is translated and "Save As" becomes "Speichern unter" — a bar +of labels has no reflow and no scrollbar to save it. Take the overlays from the +viewer's existing set (`Script_Save`, `Conv_toolbar_plus`, `TrashItem_Off`, +`Refresh_Off`, `Script_Undo`, `Script_Redo`, `Command_Stats_Icon`) rather than +adding art; picking a glyph that already means the right thing elsewhere is most +of the work. + +**A button that opens another floater needs no C++ at all** — `Floater.Toggle` +is a global commit callback in `llui.cpp`, with the floater's registered name as +`parameter`. Use that one and not `Floater.ToggleOrBringToFront`; §4d explains +why the second can only ever open. + ### 5. Height math (the part everyone gets wrong) - `accordion_tab` height **must be** inner panel height **+ 29** @@ -576,6 +674,12 @@ refresh off each of those is more places to forget than a polled compare costs. - `min_width` is the only declarative way to widen an existing user's floater (`LLFloater::applyRectControl` prefers a saved rect over the XUI width), and it force-widens everyone permanently. Design to the current width instead. +- **All of this is checkable without launching**, and worth checking that way + because the failure is silent: walk each `sec_*` panel's children, track + `top = prev_bottom + top_pad` (or the absolute `top`), and assert the panel's + declared height is the lowest bottom + 8 and the tab's is the panel's + 29. + Thirty lines of Python over the XUI, and it catches the arithmetic slip that + otherwise shows up as a row you cannot see. ### 6. Gating @@ -675,10 +779,16 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. the handle is put back where the setting actually landed. - For anything measured (scopes, vectorscope): change the thing it measures and confirm the readout moves the way the control says it should. -- For a bypass: tick it and confirm the image changes **and the Looks `*` does - not appear**. Then close the Lightbox with it still ticked and confirm the - render comes back — state parked outside the floater is the failure mode here, - and it looks like a renderer bug rather than a UI one. +- For a section switch: **untick** it (ticked is on) and confirm the image + changes **and the Looks `*` does not appear**. Then close the Lightbox with it + still unticked and confirm the render comes back — state parked outside the + floater is the failure mode here, and it looks like a renderer bug rather than + a UI one. +- For anything on an accordion header: click it and confirm the section does + **not** expand or collapse, then hover it with the section expanded and + confirm its own tooltip appears rather than the title's. Those are the two + interceptions in §3b, and the tooltip one only shows up when expanded — test + it collapsed and it will look fine. - If you added a print effect: take a snapshot with "No post-processing" ticked and confirm the effect is absent from the saved file, not just from the preview. From 229a682cc5f5d6e5318f51fdccbf7a3f6ae20a4f Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:09:01 -0500 Subject: [PATCH 27/32] Add a Sky tab: hold the day cycle still, and reach the client-side sky effects The Lightbox could grade the light but not touch it, which left the first thing a photographer does outside the tool: pin the sky, so the thing being graded is one light rather than a moving one. Freezing does not pause anything, because there is nothing to pause. DayInstance::getProgress computes the cycle position from LLDate::now every frame, so the only way to stop the motion is to sample the running cycle at one position and install the result as a fixed local environment. That is what @setenv_daytime and the day cycle editor's timeline already do; this samples the water track as well as the sky, since a frozen sky over a moving sea is not frozen. Four things about changing LLEnvironment from a floater, none of them obvious and all of them now in the guide. @setenv is enforced inside LLEnvironment rather than by callers, so an unchecked control looks live and silently does nothing under restriction; there is no enable_callback on ordinary widgets, so the rows are greyed from the same draw() poll that reads their state back. Freezing covers up ENV_LOCAL, which is where a Personal Lighting sky lives, so what was there is captured and put back when Freeze is unticked -- clearing without capturing loses someone's sky silently. Reverting invalidates every reflection probe the old sky lit. And the frozen state is read from the world rather than mirrored in a member, which keeps the tab honest when the World menu changes the environment underneath it and is what lets scrubbing survive a close and reopen. The presets are not fractions of the cycle. Nothing in this viewer maps a cycle position to a clock -- the day cycle editor labels its timeline as a percentage, and a region puts its keyframes where it likes. So ALDayCycleLandmarks samples the cycle, reads the sun's height above the horizon, and takes noon and midnight from the extremes and sunrise and sunset from the crossings, interpolated so the answer beats the sample grid. A cycle missing a landmark greys that button rather than inventing one: a sun that never sets has a noon and no sunrise. It takes a sampler rather than a day cycle, so the arithmetic is tested against a sine wave with no viewer in the way, including the phase-shifted case a hardcoded 0.5 fails. Worth knowing why that case matters: LLSettingsSky::defaults caches its position-dependent result in a static, so the viewer's own default day cycle is eight identical frames and would make a wrong implementation look right. Sky Effects exposes three effects that were drawn entirely on the client and reachable only from Debug Settings: the aurora, the meteor streaks and the star field. One section rather than three, because two of them are a single control each and a section per slider reads as filing. The star count is a dropdown and not a slider, by this file's own rule: committing it regenerates every star position and rebuilds the vertex buffer, and a slider commits on every mouse-move. None of the three joins the Looks whitelist; a Look is the Look and Lens tabs, and one that switched the aurora on would be a surprise. Also records two layout rules that only fail on screen. A row of buttons has to be sized for min_width and not the default -- 361px, not 432 -- which the preset row learned by losing its last button the moment the floater was narrowed. And a one-line text wants height="16": an overflowing one does not clip or scroll, it draws over the row beneath. Co-Authored-By: Claude Opus 5 --- doc/LIGHTBOX.md | 128 +++++- indra/newview/CMakeLists.txt | 3 + indra/newview/aldaycyclelandmarks.cpp | 129 ++++++ indra/newview/aldaycyclelandmarks.h | 96 +++++ indra/newview/alfloaterlightbox.cpp | 323 ++++++++++++++ indra/newview/alfloaterlightbox.h | 75 ++++ .../xui/en/floater_lightbox_settings.xml | 11 + .../default/xui/en/panel_lightbox_sky.xml | 402 ++++++++++++++++++ .../tests/aldaycyclelandmarks_test.cpp | 163 +++++++ 9 files changed, 1325 insertions(+), 5 deletions(-) create mode 100644 indra/newview/aldaycyclelandmarks.cpp create mode 100644 indra/newview/aldaycyclelandmarks.h create mode 100644 indra/newview/skins/default/xui/en/panel_lightbox_sky.xml create mode 100644 indra/newview/tests/aldaycyclelandmarks_test.cpp diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 91772272c3..e21572434a 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -23,6 +23,8 @@ statics), so edits preview live with no glue code. | Look tab (color science) | `.../panel_lightbox_look.xml` | | Lens tab (optical/film effects) | `.../panel_lightbox_lens.xml` | | Scene tab (quality/performance) | `.../panel_lightbox_scene.xml` | +| Sky tab (environment + client-side sky effects) | `.../panel_lightbox_sky.xml` | +| Day cycle landmark search | `indra/newview/aldaycyclelandmarks.{h,cpp}` | | The C++ (callbacks, Vec3 binder, section reset, Looks bar) | `indra/newview/alfloaterlightbox.{h,cpp}` | | Looks preset system + whitelist | `indra/newview/llpresetsmanager.{h,cpp}` | | Bundled starter Looks | `indra/newview/app_settings/looks/` | @@ -36,10 +38,10 @@ statics), so edits preview live with no glue code. | Header checkbox on `accordion_tab` | `indra/llui/llaccordionctrltab.{h,cpp}` | | Anti-aliased 2D polyline and fill | `indra/llrender/llrender2dutils.{h,cpp}` | -Five of those have unit tests, and the tests are the reason the maths in them can -be trusted: `alcolorwheelmodel_test`, `alcurvemodel_test`, `algradehistory_test`, -`alscopedata_test`, `alwhitebalancesolver_test`. Anything with arithmetic in it -belongs on that list. +Six of those have unit tests, and the tests are the reason the maths in them can +be trusted: `alcolorwheelmodel_test`, `alcurvemodel_test`, +`aldaycyclelandmarks_test`, `algradehistory_test`, `alscopedata_test`, +`alwhitebalancesolver_test`. Anything with arithmetic in it belongs on that list. ### What v2 added, and what it altered @@ -55,6 +57,7 @@ the viewer's widget set was touched. | Anti-aliased polyline and area fill | `gl_polyline_2d`, `gl_polyfill_2d` | §4b | | Floater top bar (Looks, history, scopes) | ordinary buttons, `Floater.Toggle` | §4g | | Scopes window | its own floater | §4d | +| Sky tab (day cycle freeze) | ordinary rows, `LLEnvironment` behind them | §4h | `accordion_tab` is the only **stock** widget altered, and the change is additive: a tab that does not ask for `header_check_box` gets exactly the header it always @@ -118,7 +121,11 @@ persist — declare everything you expose). ### 2. Choose the tab and shape - **Look** = color science (tone, grading). **Lens** = optical/film effects. - **Scene** = render quality and performance. + **Scene** = render quality and performance. **Sky** = the sky being shot: + client-side sky effects, which are ordinary settings rows, plus the day cycle + controls, which are the odd ones out because they reach past this floater and + change the world's environment. Read §4h before adding day cycle controls; + a sky *effect* needs nothing special. - Essentials (2-4 knobs) in the main section; long tail in a *sibling* accordion tab named `atab_sec__adv` titled `"
- Advanced"`. - **Fold instead of splitting** when the advanced tail is small (~2-3 rows) or @@ -649,6 +656,91 @@ is a global commit callback in `llui.cpp`, with the floater's registered name as `parameter`. Use that one and not `Floater.ToggleOrBringToFront`; §4d explains why the second can only ever open. +### 4h. The Sky tab, and changing the world + +The Sky tab holds two kinds of thing and they do not behave alike. + +**Sky Effects is ordinary settings rows** and wants nothing from this section: +`control_name`, a reset glyph, a Reset All, done. Aurora, meteors and the star +field are drawn entirely on the client, so nothing is sent to or from the region +and no land setting turns them on — which is what makes them safe to expose here +at all, and why they sit beside the day cycle rather than in Scene. All three +gate on the same star brightness the night sky uses, so they simply do not +appear in daylight, and an HDRI sky replaces the dome and leaves nothing to draw +into. None is on the Looks whitelist: a Look is the aesthetic settings of the +Look and Lens tabs, and a Look that switched the aurora on would be a surprise. + +Two shaping decisions in that section are worth copying. It is **one** section +rather than three because two of the effects are a single control each, and a +section per slider reads as filing rather than grouping. And the star count is a +**dropdown**, by §2's rule: committing it regenerates every star position and +rebuilds the vertex buffer, so a slider — which commits on every mouse-move — +would hitch the whole way across its own travel. + +**Day cycle is the odd one out.** It changes `LLEnvironment`, which is shared +with the whole viewer, and that makes it a different kind of thing to work on. +Six rules, all learned the hard way and all still true for anything else that +reaches out of this floater. + +**There is no clock to stop.** `DayInstance::getProgress()` computes the cycle +position from `LLDate::now()` plus the day offset, every frame, so nothing can +be paused. "Freeze" means sampling the running cycle at one position and +installing the result as a *fixed* local environment; the motion stops because +there is no longer a day cycle in effect. That is also how `@setenv_daytime` +and the day cycle editor's timeline do it — sample with +`LLTrackBlenderLoopingManual(target, day, track)->setPosition(0..1)`. + +**Sample water as well as sky.** Track 0 is water and the sky tracks are 1 to 4, +chosen by altitude via `calculateSkyTrackForAltitude`. RLVa's version freezes +only the sky, and a frozen sky over a moving sea is not frozen. + +**RLVa is enforced below you, not by you.** `setSelectedEnvironment` returns +early when `!RlvActions::canChangeEnvironment()`, inside `LLEnvironment`. A +control that does not check it looks live and silently does nothing under +`@setenv=n`, so the Light rows are greyed from the same `draw()` poll that reads +their state back. There is no `enable_callback` on ordinary widgets in XUI — +menus have `on_enable`, widgets do not — so this has to be done in C++. + +**Freezing covers up whatever `ENV_LOCAL` held**, which is where Personal +Lighting and an inventory-applied sky live. Unticking Freeze puts back what was +captured on the way in; "Restore region environment" is the unconditional way +out and *does* discard it. Clearing without capturing first is a silent way to +lose someone's sky. + +**Reverting the sky invalidates every reflection probe** that was lit by it. +`gPipeline.mReflectionMapManager.reset()`, the same call the World menu's own +revert makes. + +**Read the state from the world, not from a mirror.** Whether the sky is frozen +is `getEnvironmentFixedSky(ENV_LOCAL) != nullptr`; the cycle to scrub is the +first day found across `ENV_LOCAL`, `ENV_PUSH`, `ENV_PARCEL`, `ENV_REGION`. +Deriving both means the tab stays honest when the World menu, an attachment or +another floater changes the environment underneath it — and it is what lets +scrubbing survive closing and reopening the floater, since nothing about the +freeze is remembered in the floater at all. + +#### A cycle position is not a time + +Nothing in this viewer maps a cycle position to a clock. The day cycle editor +labels its timeline as a **percentage**, and a region can put its keyframes +wherever it likes, so "noon is 0.5" is a property of some day cycles and not +others. Even the stock day is not what you would guess: `LLSettingsSky::defaults` +computes sun altitude as `π × position`, and caches its result in a `static`, so +the viewer's own default day cycle is eight identical frames. + +So the four preset buttons do not use fractions. `ALDayCycleLandmarks::find` +samples the cycle, reads `getSunDirection().mV[VZ]` — the sun's height above the +horizon — and takes noon and midnight from the extremes and sunrise and sunset +from the horizon crossings, interpolated between samples so the answer beats the +grid. A cycle without a given landmark reports it absent and the button greys, +because a sun that never sets has a noon and no sunrise, and inventing one would +be worse than offering nothing. + +It takes a sampler rather than a day cycle, the same shape `curve_editor` uses, +which is what lets `aldaycyclelandmarks_test` exercise it with a sine wave and +no viewer around it. Sampling costs ninety-six blends, so it is cached against +the day it was computed from and never runs on the frame path. + ### 5. Height math (the part everyone gets wrong) - `accordion_tab` height **must be** inner panel height **+ 29** @@ -667,6 +759,12 @@ why the second can only ever open. `top="8"`, so the bank's bottom is *one* wheel's height, not three; the next row's `top_pad` chains from the last one declared. Get this wrong and the panel is either 350px too tall or clipped. +- **A row of buttons has to be sized for `min_width`, not for the default.** + The arithmetic that matters is 420 − 28 for the chrome − 15 for the accordion + scrollbar − 16 for `left="8"`/`right="-8"`, which leaves **361px**. The Light + tab's four presets are 85 wide with 6px gaps and end at 366 of 369. Laid out + against the 460 default they looked fine and lost their last button the + moment the floater was narrowed. - Widths never reflow. Usable inner width is the floater's width − 28, and ~15px less again whenever the accordion's scrollbar shows. **Overflow clips silently, with no scrollbar and no warning**, so check the narrowest case: @@ -715,6 +813,18 @@ only; apply per row, not on the parent panel. Reference patterns: which is a quantisation aid rather than a look and which an 8-bit PNG wants either way. +Two things that only show up on screen, both of which did: + +- **A one-line `text` needs `height="16"`, not `height="30"` with `word_wrap`.** + Three lines of prose in a 30px box do not scroll or ellipse, they draw over + the row beneath. Count the characters: roughly 70 fit on a line at + `SansSerifSmall` across a section at `min_width`. +- **A button's `width` has to fit its own label**, and an `image_overlay` eats + 18px of it before the text starts. There is no reflow and no ellipsis; the + label is simply cut. Prefer a name the viewer already uses — "Use shared + environment" is the World menu's own wording for dropping a local environment + — over a longer one you invent. + ### 7. Slider text width Any slider with `max_val` below 1.0 (or ≤ 0) **must** set an explicit @@ -784,6 +894,14 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. still unticked and confirm the render comes back — state parked outside the floater is the failure mode here, and it looks like a renderer bug rather than a UI one. +- For the Sky tab's day cycle: freeze, wait past the point the sky would have + moved, and confirm it has not. Then untick and confirm you get back *what you + had*, not the region default — set a Personal Lighting sky first, since that + is the case a missing capture loses. Fly through an altitude band while frozen + and confirm the sky holds; check the water stopped too, not just the sky. + Finally, confirm the presets land somewhere plausible on a region whose day + cycle is not the default one, because a hardcoded fraction would also look + right on a default region. - For anything on an accordion header: click it and confirm the section does **not** expand or collapse, then hover it with the section expanded and confirm its own tooltip appears rather than the title's. Those are the two diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index bc1ecfc3d6..012f2f63d9 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -161,6 +161,7 @@ set(viewer_SOURCE_FILES alcolorwheelmodel.cpp alcurveeditorctrl.cpp alcurvemodel.cpp + aldaycyclelandmarks.cpp alderenderlist.cpp aldroptarget.cpp alfloaterblocked.cpp @@ -939,6 +940,7 @@ set(viewer_HEADER_FILES alcolorwheelmodel.h alcurveeditorctrl.h alcurvemodel.h + aldaycyclelandmarks.h alderenderlist.h aldroptarget.h alfloaterblocked.h @@ -2393,6 +2395,7 @@ if (BUILD_TESTING) SET(viewer_TEST_SOURCE_FILES alcolorwheelmodel.cpp alcurvemodel.cpp + aldaycyclelandmarks.cpp algradehistory.cpp alsceneexplorerpredicate.cpp alscopedata.cpp diff --git a/indra/newview/aldaycyclelandmarks.cpp b/indra/newview/aldaycyclelandmarks.cpp new file mode 100644 index 0000000000..f911680321 --- /dev/null +++ b/indra/newview/aldaycyclelandmarks.cpp @@ -0,0 +1,129 @@ +/** + * @file aldaycyclelandmarks.cpp + * @brief Finding sunrise, noon, sunset and midnight in an arbitrary day cycle + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "aldaycyclelandmarks.h" + +#include +#include + +namespace ALDayCycleLandmarks +{ + +namespace +{ +/// Below this much variation in altitude across the whole cycle there is no +/// meaningful high or low point to name. Well under a degree of arc, so a +/// cycle a person would call static reads as static, and any cycle with real +/// sun movement in it clears this by orders of magnitude. +constexpr F32 FLAT_CYCLE_EPSILON = 1e-4f; + +/// Where the segment from (0, a) to (step, b) crosses zero, as an offset into +/// the segment. Only called when a and b straddle zero, so the denominator +/// cannot vanish. +F32 crossingOffset(F32 a, F32 b, F32 step) +{ + return step * (-a / (b - a)); +} +} // namespace + +Landmarks find(const altitude_sampler_t& sampler, S32 samples) +{ + Landmarks out; + + if (!sampler || samples < 2) + { + return out; + } + + const F32 step = 1.f / (F32)samples; + + std::vector altitude((size_t)samples, 0.f); + for (S32 i = 0; i < samples; ++i) + { + altitude[(size_t)i] = sampler((F32)i * step); + } + + S32 highest = 0; + S32 lowest = 0; + for (S32 i = 1; i < samples; ++i) + { + if (altitude[(size_t)i] > altitude[(size_t)highest]) + { + highest = i; + } + if (altitude[(size_t)i] < altitude[(size_t)lowest]) + { + lowest = i; + } + } + + // A cycle that does not move the sun has no moment worth jumping to, and + // saying so is better than handing back position zero four times over. + if ((altitude[(size_t)highest] - altitude[(size_t)lowest]) < FLAT_CYCLE_EPSILON) + { + return out; + } + + // Noon and midnight are the extremes, but only where the horizon makes + // them mean what they are called. A sun that stays up all cycle has a + // brightest moment and no midnight; one that never rises has neither. + if (altitude[(size_t)highest] > 0.f) + { + out.has_noon = true; + out.noon = (F32)highest * step; + } + if (altitude[(size_t)lowest] < 0.f) + { + out.has_midnight = true; + out.midnight = (F32)lowest * step; + } + + // The cycle wraps, so the last sample's neighbour is the first: a sunrise + // sitting across the seam is still a sunrise. + for (S32 i = 0; i < samples; ++i) + { + const F32 here = altitude[(size_t)i]; + const F32 next = altitude[(size_t)((i + 1) % samples)]; + + if (here < 0.f && next >= 0.f && !out.has_sunrise) + { + out.has_sunrise = true; + out.sunrise = std::fmod((F32)i * step + crossingOffset(here, next, step), 1.f); + } + else if (here >= 0.f && next < 0.f && !out.has_sunset) + { + out.has_sunset = true; + out.sunset = std::fmod((F32)i * step + crossingOffset(here, next, step), 1.f); + } + + if (out.has_sunrise && out.has_sunset) + { + break; + } + } + + return out; +} + +} // namespace ALDayCycleLandmarks diff --git a/indra/newview/aldaycyclelandmarks.h b/indra/newview/aldaycyclelandmarks.h new file mode 100644 index 0000000000..89e035dbc8 --- /dev/null +++ b/indra/newview/aldaycyclelandmarks.h @@ -0,0 +1,96 @@ +/** + * @file aldaycyclelandmarks.h + * @brief Finding sunrise, noon, sunset and midnight in an arbitrary day cycle + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#ifndef AL_DAYCYCLELANDMARKS_H +#define AL_DAYCYCLELANDMARKS_H + +#include "stdtypes.h" + +#include + +/// Where the interesting moments are in a day cycle. +/// +/// A cycle position is a fraction of the whole cycle and means nothing on its +/// own: nothing in the viewer maps position to a clock. The day cycle editor +/// labels its timeline as a percentage for exactly that reason, and a region +/// can put its keyframes anywhere it likes, so "noon is 0.5" is true of some +/// day cycles and false of others. The only honest way to find noon is to ask +/// where the sun is highest, which is what this does. +/// +/// It takes a sampler rather than a day cycle so that the arithmetic can be +/// tested without a viewer around it -- the same shape `curve_editor` uses, +/// and for the same reason. The caller supplies "sun altitude at this +/// position"; whether that comes from blending a real `LLSettingsDay` or from +/// a formula in a test is not this code's business. +namespace ALDayCycleLandmarks +{ + +/// Positions in [0, 1). A landmark a cycle does not have is reported absent +/// rather than guessed at: a sun that never sets has no sunrise, and a day +/// built from one repeated frame has nothing at all. +struct Landmarks +{ + bool has_sunrise = false; + bool has_noon = false; + bool has_sunset = false; + bool has_midnight = false; + + F32 sunrise = 0.f; + F32 noon = 0.f; + F32 sunset = 0.f; + F32 midnight = 0.f; + + bool any() const { return has_sunrise || has_noon || has_sunset || has_midnight; } +}; + +/// Sun altitude at a cycle position: the vertical component of the sun's +/// direction, so +1 is overhead, 0 is exactly on the horizon and negative is +/// below it. +using altitude_sampler_t = std::function; + +/// Default sample count. 96 samples is under four minutes of resolution on a +/// cycle mapped to a 24 hour day, which is finer than the eye reads off a +/// slider, and the horizon crossings are interpolated between samples rather +/// than snapped to one, so the sunrise and sunset it finds are better than +/// the grid. +constexpr S32 DEFAULT_SAMPLES = 96; + +/// Sample the cycle and pick out its landmarks. +/// +/// Noon and midnight are the highest and lowest the sun gets. Sunrise and +/// sunset are where it crosses the horizon going up and going down, taken +/// from the first crossing of each kind so that a cycle with several is +/// answered the same way every time. +/// +/// A cycle whose altitude never varies has no landmarks -- there is no moment +/// in it to single out -- and one whose sun never sets has a noon but no +/// sunrise, sunset or midnight, because those three are defined by the +/// horizon and it never reaches it. +Landmarks find(const altitude_sampler_t& sampler, S32 samples = DEFAULT_SAMPLES); + +} // namespace ALDayCycleLandmarks + +#endif // AL_DAYCYCLELANDMARKS_H diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index ea6cfe2c39..548b373d56 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -38,14 +38,18 @@ #include "alcurvemodel.h" #include "altoolscenepicker.h" #include "alwhitebalancesolver.h" +#include "llagent.h" +#include "llenvironment.h" #include "llnotificationsutil.h" #include "llpanel.h" #include "llpresetsmanager.h" +#include "llsettingsvo.h" #include "llspinctrl.h" #include "lltimer.h" #include "lltoolmgr.h" #include "llviewercontrol.h" #include "pipeline.h" +#include "rlvactions.h" #include #include @@ -155,6 +159,11 @@ ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) mCommitCallbackRegistrar.add("LightBox.ToggleSection", std::bind(&ALFloaterLightBox::onToggleSection, this, std::placeholders::_1, std::placeholders::_2)); mCommitCallbackRegistrar.add("LightBox.ReferenceGrab", std::bind(&ALFloaterLightBox::onClickReferenceGrab, this)); mCommitCallbackRegistrar.add("LightBox.ReferenceClear", std::bind(&ALFloaterLightBox::onClickReferenceClear, this)); + mCommitCallbackRegistrar.add("LightBox.ToggleDayFreeze", std::bind(&ALFloaterLightBox::onToggleDayFreeze, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.CommitDayTime", std::bind(&ALFloaterLightBox::onCommitDayTime, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.DayPreset", std::bind(&ALFloaterLightBox::onClickDayPreset, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ToggleCloudScroll", std::bind(&ALFloaterLightBox::onToggleCloudScroll, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.RestoreEnvironment", std::bind(&ALFloaterLightBox::onClickRestoreEnvironment, this)); // Lambdas rather than bind: applyHistory answers whether it did anything, // and a commit callback returns nothing, so say plainly that the answer is // not wanted here. The buttons are greyed when there is nothing to do. @@ -218,6 +227,13 @@ bool ALFloaterLightBox::postBuild() mReferenceMode = findChild("reference_mode"); mReferencePosition = findChild("reference_position"); + mDayFreeze = findChild("day_freeze"); + mDayTime = findChild("day_time"); + mCloudScroll = findChild("day_pause_clouds"); + mRestoreEnvironment = findChild("day_restore_environment"); + mDayPresets = { findChild("day_sunrise"), findChild("day_noon"), + findChild("day_sunset"), findChild("day_midnight") }; + // Undo watches exactly the settings a Look carries. Sharing that list is // the point: a control worth saving is a control worth undoing, so a new // grading setting joins both at once instead of one and not the other. @@ -485,9 +501,316 @@ void ALFloaterLightBox::draw() { refreshReferenceRow(); refreshHistoryButtons(); + refreshDayCycleRow(); LLFloater::draw(); } +std::shared_ptr ALFloaterLightBox::getScrubbableDay() const +{ + // The order the viewer itself resolves environments in. ENV_LOCAL leads + // because a day the user applied is the one they would expect to scrub; + // while frozen it holds a fixed sky and has no day, so the search falls + // through to whatever the parcel or region is running. That fall-through + // is also what lets scrubbing keep working after the floater has been + // closed and reopened, since nothing about it is remembered here. + static const LLEnvironment::EnvSelection_t sources[] = { + LLEnvironment::ENV_LOCAL, LLEnvironment::ENV_PUSH, + LLEnvironment::ENV_PARCEL, LLEnvironment::ENV_REGION }; + + for (LLEnvironment::EnvSelection_t env : sources) + { + if (LLSettingsDay::ptr_t day = LLEnvironment::instance().getEnvironmentDay(env)) + { + return day; + } + } + return {}; +} + +bool ALFloaterLightBox::isSkyFrozen() const +{ + return (bool)LLEnvironment::instance().getEnvironmentFixedSky(LLEnvironment::ENV_LOCAL); +} + +void ALFloaterLightBox::applyDayPosition(F32 position) +{ + LLSettingsDay::ptr_t day = getScrubbableDay(); + if (!day) + { + return; + } + + mDayPosition = llclamp(position, 0.f, 1.f); + + // A day cycle carries up to four sky tracks and which one you see depends + // on how high you are, so sample the one the agent is actually in. Track 0 + // is water, always, and it gets sampled too: the day cycle editor blends + // both and a frozen sky over a moving sea is not frozen. + const S32 track = LLEnvironment::instance().calculateSkyTrackForAltitude( + gAgent.getPositionAgent().mV[VZ]); + + LLSettingsSky::ptr_t sky = LLSettingsVOSky::buildDefaultSky(); + LLSettingsWater::ptr_t water = LLSettingsVOWater::buildDefaultWater(); + + // make_shared rather than a temporary: LLSettingsBlender is held by shared + // pointer internally, which is how the day cycle editor and @setenv_daytime + // both spell this. + std::make_shared(sky, day, track)->setPosition(mDayPosition); + std::make_shared(water, day, (S32)LLSettingsDay::TRACK_WATER) + ->setPosition(mDayPosition); + + LLEnvironment::instance().setEnvironment(LLEnvironment::ENV_LOCAL, sky, water); + LLEnvironment::instance().setSelectedEnvironment(LLEnvironment::ENV_LOCAL, LLEnvironment::TRANSITION_INSTANT); + LLEnvironment::instance().updateEnvironment(LLEnvironment::TRANSITION_INSTANT); +} + +void ALFloaterLightBox::freezeSkyAt(F32 position) +{ + if (!mDayFreezeIsOurs) + { + // Whatever is here now is about to be covered up, and unticking Freeze + // has to give it back. Without this, freezing over a Personal Lighting + // sky and unfreezing again would quietly drop the user back to the + // region default and lose their sky. + LLEnvironment& env = LLEnvironment::instance(); + mPreFreezeDay = env.getEnvironmentDay(LLEnvironment::ENV_LOCAL); + if (mPreFreezeDay) + { + mPreFreezeDayLength = env.getEnvironmentDayLength(LLEnvironment::ENV_LOCAL).value(); + mPreFreezeDayOffset = env.getEnvironmentDayOffset(LLEnvironment::ENV_LOCAL).value(); + } + const LLEnvironment::fixedEnvironment_t fixed = env.getEnvironmentFixed(LLEnvironment::ENV_LOCAL); + mPreFreezeSky = fixed.first; + mPreFreezeWater = fixed.second; + mDayFreezeIsOurs = true; + } + + applyDayPosition(position); +} + +void ALFloaterLightBox::thawSky() +{ + LLEnvironment& env = LLEnvironment::instance(); + + // Every reflection probe in the scene was lit by the sky being replaced. + // The World menu's own revert does this for the same reason; without it + // the first frames after a thaw carry the light of the sky just left. + gPipeline.mReflectionMapManager.reset(); + + if (mDayFreezeIsOurs && mPreFreezeDay) + { + env.setEnvironment(LLEnvironment::ENV_LOCAL, mPreFreezeDay, + LLSettingsDay::Seconds(mPreFreezeDayLength), + LLSettingsDay::Seconds(mPreFreezeDayOffset)); + } + else if (mDayFreezeIsOurs && (mPreFreezeSky || mPreFreezeWater)) + { + env.setEnvironment(LLEnvironment::ENV_LOCAL, mPreFreezeSky, mPreFreezeWater); + } + else + { + // Nothing to give back, so fall through to the parcel or region. + env.clearEnvironment(LLEnvironment::ENV_LOCAL); + } + + env.setSelectedEnvironment(LLEnvironment::ENV_LOCAL, LLEnvironment::TRANSITION_INSTANT); + env.updateEnvironment(LLEnvironment::TRANSITION_INSTANT); + + mPreFreezeDay.reset(); + mPreFreezeSky.reset(); + mPreFreezeWater.reset(); + mDayFreezeIsOurs = false; +} + +void ALFloaterLightBox::refreshDayLandmarks() +{ + LLSettingsDay::ptr_t day = getScrubbableDay(); + if (day == mLandmarkDay) + { + return; + } + + mLandmarkDay = day; + mDayLandmarks = ALDayCycleLandmarks::Landmarks(); + if (!day) + { + return; + } + + // One scratch sky, re-blended at each sample. getSunDirection's Z is the + // sun's height above the horizon, which is the only thing that says where + // noon is in a cycle whose keyframes could be anywhere. + const S32 track = LLEnvironment::instance().calculateSkyTrackForAltitude( + gAgent.getPositionAgent().mV[VZ]); + LLSettingsSky::ptr_t scratch = LLSettingsVOSky::buildDefaultSky(); + auto blender = std::make_shared(scratch, day, track); + + mDayLandmarks = ALDayCycleLandmarks::find( + [&blender, &scratch](F32 position) + { + blender->setPosition(position); + return scratch->getSunDirection().mV[VZ]; + }); +} + +void ALFloaterLightBox::refreshDayCycleRow() +{ + if (!mDayFreeze) + { + return; // the XUI is free to drop the row + } + + const bool can_change = RlvActions::canChangeEnvironment(); + const bool frozen = isSkyFrozen(); + const bool has_day = (bool)getScrubbableDay(); + + // While the sky is running, the slider shows where it actually is, so + // ticking Freeze holds the moment being looked at rather than jumping. + if (!frozen) + { + const F32 live = LLEnvironment::instance().getProgress(); + if (live >= 0.f) + { + mDayPosition = live; + if (mDayTime) + { + mDayTime->setValue(mDayPosition); + } + } + // A sky frozen by something else, then cleared by it, leaves us + // holding a restore point for an environment that is already back. + mDayFreezeIsOurs = false; + } + + // Clouds are polled alongside because the World menu can pause them too, + // and a checkbox that disagrees with the sky is worse than none. + const bool clouds_paused = LLEnvironment::instance().isCloudScrollPaused(); + + const S32 state = (can_change ? 1 : 0) | (frozen ? 2 : 0) | (has_day ? 4 : 0) + | (clouds_paused ? 8 : 0); + if (state == mDayCycleRowState) + { + return; + } + mDayCycleRowState = state; + + if (mCloudScroll) + { + mCloudScroll->setValue(clouds_paused); + } + + // @setenv is enforced inside LLEnvironment, not here, so without this the + // controls would look live and do nothing at all under a restriction. + mDayFreeze->setEnabled(can_change && has_day); + mDayFreeze->setValue(frozen); + if (mDayTime) + { + mDayTime->setEnabled(can_change && frozen && has_day); + } + if (mRestoreEnvironment) + { + mRestoreEnvironment->setEnabled(can_change && frozen); + } + + // Presets cost a search, so only look when the row is actually usable. + if (can_change && has_day) + { + refreshDayLandmarks(); + } + const bool present[4] = { mDayLandmarks.has_sunrise, mDayLandmarks.has_noon, + mDayLandmarks.has_sunset, mDayLandmarks.has_midnight }; + for (size_t i = 0; i < mDayPresets.size(); ++i) + { + if (mDayPresets[i]) + { + mDayPresets[i]->setEnabled(can_change && has_day && present[i]); + } + } +} + +void ALFloaterLightBox::onToggleDayFreeze(LLUICtrl* ctrl) +{ + if (!ctrl) + { + return; + } + + if (ctrl->getValue().asBoolean()) + { + freezeSkyAt(mDayPosition); + } + else + { + thawSky(); + } + mDayCycleRowState = -1; +} + +void ALFloaterLightBox::onCommitDayTime(LLUICtrl* ctrl) +{ + if (ctrl) + { + applyDayPosition((F32)ctrl->getValue().asReal()); + } +} + +void ALFloaterLightBox::onClickDayPreset(const LLSD& userdata) +{ + refreshDayLandmarks(); + + const std::string& which = userdata.asString(); + const ALDayCycleLandmarks::Landmarks& marks = mDayLandmarks; + + F32 position = 0.f; + if (which == "sunrise" && marks.has_sunrise) { position = marks.sunrise; } + else if (which == "noon" && marks.has_noon) { position = marks.noon; } + else if (which == "sunset" && marks.has_sunset) { position = marks.sunset; } + else if (which == "midnight" && marks.has_midnight) { position = marks.midnight; } + else { return; } + + // A preset moves the slider and freezes there; it does not install a + // canned sky, so the region's own idea of noon is what you get and you can + // keep scrubbing from it. + freezeSkyAt(position); + if (mDayTime) + { + mDayTime->setValue(mDayPosition); + } + mDayCycleRowState = -1; +} + +void ALFloaterLightBox::onToggleCloudScroll(LLUICtrl* ctrl) +{ + if (!ctrl) + { + return; + } + + // Clouds drift on their own timer, entirely apart from the day cycle, so a + // frozen sky with this off still has weather moving through it. + if (ctrl->getValue().asBoolean()) + { + LLEnvironment::instance().pauseCloudScroll(); + } + else + { + LLEnvironment::instance().resumeCloudScroll(); + } +} + +void ALFloaterLightBox::onClickRestoreEnvironment() +{ + // Unconditional, unlike unticking Freeze: this is the way back to the + // parcel or region whatever we happen to be holding, including a sky some + // other floater installed. + mDayFreezeIsOurs = false; + mPreFreezeDay.reset(); + mPreFreezeSky.reset(); + mPreFreezeWater.reset(); + thawSky(); + mDayCycleRowState = -1; +} + void ALFloaterLightBox::onGradeSettingChanged(const std::string& name, const LLSD& before, const LLSD& after) { if (mApplyingHistory) diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 6f59963a72..4d95b6663a 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -35,15 +35,20 @@ #include "llfloater.h" +#include "aldaycyclelandmarks.h" #include "algradehistory.h" #include #include +#include #include #include class ALCurveEditorCtrl; class LLComboBox; +class LLSettingsDay; +class LLSettingsSky; +class LLSettingsWater; class LLSpinCtrl; class ALFloaterLightBox final : public LLFloater @@ -99,6 +104,42 @@ class ALFloaterLightBox final : public LLFloater /// Grey Undo and Redo to match what the stack can actually do. void refreshHistoryButtons(); + // --- Day cycle --- + // + // Nothing here pauses a clock, because there is no clock to pause: the + // cycle position is computed from wall time every frame. Freezing means + // sampling the running cycle at one position and installing the result as + // a *fixed* local environment, which is what stops the motion. It is the + // same move `@setenv_daytime` and the day cycle editor's timeline make. + + /// The day cycle to scrub, from the highest-priority layer that has one. + /// While frozen, ENV_LOCAL holds a fixed sky and no day at all, so the + /// cycle has to be read from the parcel or region underneath it -- which + /// is also what lets scrubbing survive closing and reopening the floater. + std::shared_ptr getScrubbableDay() const; + /// Whether a fixed sky is installed locally. Derived from the world rather + /// than remembered here, so it stays true when something else changes the + /// environment behind our back. + bool isSkyFrozen() const; + /// Sample the day at `position` and install it as the local environment. + void applyDayPosition(F32 position); + /// Remember what we are covering, then freeze. + void freezeSkyAt(F32 position); + /// Put back whatever ENV_LOCAL held before the freeze, or clear it. + void thawSky(); + /// Re-find the landmarks if the underlying day cycle has changed. Blends + /// ninety-six skies, so it is guarded on the day itself, not called per + /// frame. + void refreshDayLandmarks(); + /// Track the world's state and grey what cannot act on it. + void refreshDayCycleRow(); + + void onToggleDayFreeze(LLUICtrl* ctrl); + void onCommitDayTime(LLUICtrl* ctrl); + void onClickDayPreset(const LLSD& userdata); + void onToggleCloudScroll(LLUICtrl* ctrl); + void onClickRestoreEnvironment(); + /// Record one whitelisted setting moving, unless we are the ones moving it. void onGradeSettingChanged(const std::string& name, const LLSD& before, const LLSD& after); /// Step the history one transaction and write the values back. @@ -167,6 +208,40 @@ class ALFloaterLightBox final : public LLFloater /// What the row was last told, so a poll that changes nothing costs /// nothing. Tri-state: -1 until the first refresh has run. S32 mReferenceRowState = -1; + + // Day cycle row, cached for the same reason and equally optional. + LLUICtrl* mDayFreeze = nullptr; + LLUICtrl* mDayTime = nullptr; + LLUICtrl* mCloudScroll = nullptr; + LLUICtrl* mRestoreEnvironment = nullptr; + /// Sunrise, noon, sunset, midnight, in the order the landmarks are named. + std::array mDayPresets = {}; + + /// Where the slider is. While the sky is running this tracks the live + /// position, so ticking Freeze holds the moment being looked at rather + /// than jumping somewhere else first. + F32 mDayPosition = 0.f; + /// Landmarks, and the day they were found in. Comparing the day is what + /// keeps the search off the frame path. + std::shared_ptr mLandmarkDay; + ALDayCycleLandmarks::Landmarks mDayLandmarks; + + /// What ENV_LOCAL held before we froze it. Restoring this is what makes + /// unticking Freeze an undo instead of a drop to the region default, which + /// would silently discard a Personal Lighting sky. + std::shared_ptr mPreFreezeDay; + std::shared_ptr mPreFreezeSky; + std::shared_ptr mPreFreezeWater; + /// Whole seconds: LLSettingsDay::Seconds is S32Seconds, unlike the F64 one + /// on LLSettingsBase, and holding these as F32 would convert lossily on + /// the way back in. + S32 mPreFreezeDayLength = 0; + S32 mPreFreezeDayOffset = 0; + /// Whether the fixed sky in place is one we installed. False for a sky the + /// user set some other way, which we must not claim to be able to undo. + bool mDayFreezeIsOurs = false; + /// Bit 0 may change the environment, bit 1 frozen, bit 2 a day to scrub. + S32 mDayCycleRowState = -1; }; #endif // AL_FLOATERLIGHTBOX_H diff --git a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml index fd833ac364..c9df0fc012 100644 --- a/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml +++ b/indra/newview/skins/default/xui/en/floater_lightbox_settings.xml @@ -192,5 +192,16 @@ label="Scene" layout="topleft" name="tab_scene" /> + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml b/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml new file mode 100644 index 0000000000..00a886f6e7 --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml @@ -0,0 +1,402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/tests/aldaycyclelandmarks_test.cpp b/indra/newview/tests/aldaycyclelandmarks_test.cpp new file mode 100644 index 0000000000..28852cb9e4 --- /dev/null +++ b/indra/newview/tests/aldaycyclelandmarks_test.cpp @@ -0,0 +1,163 @@ +/** + * @file aldaycyclelandmarks_test.cpp + * @brief Unit tests for finding landmarks in a day cycle + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../aldaycyclelandmarks.h" + +#include + +namespace +{ + // File scope rather than a fixture member: TUT's test bodies reach the + // fixture through a dependent base, so a name inherited from it is not + // visible inside a lambda written in one. + constexpr F32 TWO_PI = 6.28318530718f; +} + +namespace tut +{ + struct daycycle_landmarks + { + /// The ordinary case: one rise and one set per cycle, noon halfway + /// between them. Offsetting the phase moves every landmark with it, + /// which is the whole point of searching rather than assuming. + static ALDayCycleLandmarks::altitude_sampler_t sine(F32 phase = 0.f) + { + return [phase](F32 p) { return std::sin(TWO_PI * (p - phase)); }; + } + + /// How far apart two cycle positions are, the short way round. A + /// landmark at 0.999 and one at 0.001 are neighbours, not opposites. + static F32 apart(F32 a, F32 b) + { + const F32 d = std::fabs(a - b); + return (d > 0.5f) ? (1.f - d) : d; + } + }; + + typedef test_group landmark_group; + typedef landmark_group::object landmark_object; + tut::landmark_group lg("ALDayCycleLandmarks"); + + // A plain sine puts sunrise at 0, noon at a quarter, sunset at a half and + // midnight at three quarters. + template<> template<> + void landmark_object::test<1>() + { + const auto marks = ALDayCycleLandmarks::find(sine()); + + ensure("has sunrise", marks.has_sunrise); + ensure("has noon", marks.has_noon); + ensure("has sunset", marks.has_sunset); + ensure("has midnight", marks.has_midnight); + + ensure("sunrise at 0", apart(marks.sunrise, 0.f) < 0.02f); + ensure("noon at 0.25", apart(marks.noon, 0.25f) < 0.02f); + ensure("sunset at 0.5", apart(marks.sunset, 0.5f) < 0.02f); + ensure("midnight at 0.75", apart(marks.midnight, 0.75f) < 0.02f); + } + + // Every landmark moves with the cycle. This is the case that a hardcoded + // "noon is 0.5" gets wrong, and the reason this code exists. + template<> template<> + void landmark_object::test<2>() + { + const F32 phase = 0.3f; + const auto marks = ALDayCycleLandmarks::find(sine(phase)); + + ensure("sunrise moved", apart(marks.sunrise, phase) < 0.02f); + ensure("noon moved", apart(marks.noon, phase + 0.25f) < 0.02f); + ensure("sunset moved", apart(marks.sunset, std::fmod(phase + 0.5f, 1.f)) < 0.02f); + ensure("midnight moved", apart(marks.midnight, std::fmod(phase + 0.75f, 1.f)) < 0.02f); + } + + // A crossing that falls between two samples is interpolated, so the answer + // is better than the sample grid rather than snapped to it. + template<> template<> + void landmark_object::test<3>() + { + // Sunrise sits at 0.1, which no 16-sample grid position lands on. + const auto marks = ALDayCycleLandmarks::find(sine(0.1f), 16); + const F32 grid = 1.f / 16.f; + + ensure("has sunrise", marks.has_sunrise); + ensure("beats the grid", apart(marks.sunrise, 0.1f) < grid * 0.5f); + } + + // A sun that never sets has a brightest moment and nothing else: naming a + // sunrise there would be inventing one. + template<> template<> + void landmark_object::test<4>() + { + const auto marks = ALDayCycleLandmarks::find( + [](F32 p) { return 0.5f + 0.25f * std::sin(TWO_PI * p); }); + + ensure("has noon", marks.has_noon); + ensure("no sunrise", !marks.has_sunrise); + ensure("no sunset", !marks.has_sunset); + ensure("no midnight", !marks.has_midnight); + ensure("noon at the peak", apart(marks.noon, 0.25f) < 0.02f); + } + + // A sun that never rises is the same argument the other way up. + template<> template<> + void landmark_object::test<5>() + { + const auto marks = ALDayCycleLandmarks::find( + [](F32 p) { return -0.5f + 0.25f * std::sin(TWO_PI * p); }); + + ensure("has midnight", marks.has_midnight); + ensure("no noon", !marks.has_noon); + ensure("no sunrise", !marks.has_sunrise); + ensure("no sunset", !marks.has_sunset); + } + + // A cycle built from one repeated frame -- which is what the viewer's own + // default day cycle actually is -- has no moment to single out. + template<> template<> + void landmark_object::test<6>() + { + const auto marks = ALDayCycleLandmarks::find([](F32) { return 0.5f; }); + + ensure("nothing to find", !marks.any()); + } + + // Landmarks are cycle positions, so they stay inside [0, 1) even when the + // crossing they came from sits across the seam. + template<> template<> + void landmark_object::test<7>() + { + for (S32 i = 0; i < 20; ++i) + { + const F32 phase = (F32)i / 20.f; + const auto marks = ALDayCycleLandmarks::find(sine(phase)); + + ensure("sunrise in range", marks.sunrise >= 0.f && marks.sunrise < 1.f); + ensure("noon in range", marks.noon >= 0.f && marks.noon < 1.f); + ensure("sunset in range", marks.sunset >= 0.f && marks.sunset < 1.f); + ensure("midnight in range", marks.midnight >= 0.f && marks.midnight < 1.f); + } + } + + // Nothing to sample with, nothing to say. + template<> template<> + void landmark_object::test<8>() + { + ensure("no sampler", !ALDayCycleLandmarks::find(nullptr).any()); + ensure("too few samples", !ALDayCycleLandmarks::find(sine(), 1).any()); + } +} From 71863f7c5efee09463b7837da1196fd2d1e75b6d Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:18:22 -0500 Subject: [PATCH 28/32] Drop the render order note from the Color Grading section Which order the renderer applies the grading sections in, and that White Balance and Lift / Gamma / Gain act in linear light while everything from Basic down acts on display values, is a maintainer's question. It is in doc/LIGHTBOX.md, where it is useful to someone adding a section; in the floater it was two lines of theory in front of a person trying to grade a picture. The order of the sections down the accordion still says it for anyone who cares to notice. What is left is the one line saying the header checkboxes exist, which is not an explanation but discoverability: the switch is a small box on a header that is easy to look straight past, and a control nobody finds may as well not be there. Its tooltip keeps the part a grader needs, that switching a section off saves nothing and does not dirty the Look. Renamed to grading_compare_note, since grading_order_note described what it no longer says. The section is the only one in the tab that opens by default, so the 14px comes back on every look at it: 140 to 126. Co-Authored-By: Claude Opus 5 --- .../default/xui/en/panel_lightbox_look.xml | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index 297f074696..f7f35ce6a1 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -523,7 +523,7 @@ Row conventions: @@ -540,32 +540,33 @@ Row conventions: + every look at the tab. All that is left is the one line that + says the header checkboxes exist, since a control nobody + finds may as well not. Which order the renderer applies the + sections in is a maintainer's question, not a grader's, and + it lives in doc/LIGHTBOX.md where the answer is useful. --> + name="grading_compare_note" + tool_tip="Switching a section off saves nothing and does not mark the Look as changed, and every section comes back when the Lightbox closes." + value="Untick a section's header checkbox to switch it off while you look." /> @@ -590,7 +591,7 @@ Row conventions: height="18" width="64" left="76" - top="66" + top="52" label="Clear" name="reference_clear" tool_tip="Discard the reference still and return to the live image"> @@ -601,7 +602,7 @@ Row conventions: follows="left|top" layout="topleft" left="148" - top="65" + top="51" width="130" height="20" name="reference_mode" @@ -624,7 +625,7 @@ Row conventions: follows="left|top|right" layout="topleft" left="8" - top="90" + top="76" right="-8" height="16" label="Wipe position" @@ -644,7 +645,7 @@ Row conventions: height="18" width="100" right="-8" - top="114" + top="100" label="Reset All" halign="left" scale_image="true" From 9db6bc823d3eeb79cf84aa1c04e9c599daee2415 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:04:39 -0500 Subject: [PATCH 29/32] Fix the scope resize readback and the day scrub's sky capture Two fixes from the lightbox-v2 branch review: - captureScopeSample reallocated the sample target when the window's aspect or AlchemyScopeSampleWidth changed, but kept the two pixel-pack buffers at their first-frame size. A grown sample then had its glReadPixels refused (GL_INVALID_OPERATION against the too-small store) and the next collect memcpy'd past the end of the mapping. The buffers are now dropped and rebuilt alongside the target. - The day slider went through applyDayPosition, which skips the pre-freeze capture freezeSkyAt does. Scrubbing while a fixed sky someone else installed (Personal Lighting, say) was live overwrote it uncaptured, so unticking Freeze dropped to the region default instead of giving the sky back. The slider now takes the same freezeSkyAt path as the preset buttons; once the freeze is ours the capture short-circuits, so drags cost nothing extra per tick. Co-Authored-By: Claude Fable 5 --- indra/newview/alfloaterlightbox.cpp | 10 +++++++++- indra/newview/pipeline.cpp | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 548b373d56..9ce9b80e0b 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -750,7 +750,15 @@ void ALFloaterLightBox::onCommitDayTime(LLUICtrl* ctrl) { if (ctrl) { - applyDayPosition((F32)ctrl->getValue().asReal()); + // Through freezeSkyAt, not applyDayPosition, even though the slider is + // only live while the sky is already frozen: "frozen" can also mean a + // fixed sky somebody else installed -- Personal Lighting, say -- which + // we have not captured. The first scrub over one of those is what + // covers it up, so it is the moment to remember it, or unticking + // Freeze drops the user to the region default instead of giving their + // sky back. Once the freeze is ours the capture is skipped and this is + // applyDayPosition, so a drag costs nothing extra per tick. + freezeSkyAt((F32)ctrl->getValue().asReal()); } } diff --git a/indra/newview/pipeline.cpp b/indra/newview/pipeline.cpp index 65fd209f01..4dde7dd572 100644 --- a/indra/newview/pipeline.cpp +++ b/indra/newview/pipeline.cpp @@ -7572,8 +7572,20 @@ void LLPipeline::captureScopeSample(LLRenderTarget* src) if (mScopeSample.getWidth() != (U32)width || mScopeSample.getHeight() != (U32)height) { // Resolution changed under us (window resize, or the debug key moved). - // Anything in flight was measured against the old size, so drop it. + // Anything in flight was measured against the old size, so drop it -- + // and the pack buffers with it, since their stores were sized to that + // old frame. Left alone, a grown sample reads past the end of the old + // store on the next collect: glReadPixels into the too-small buffer is + // refused with GL_INVALID_OPERATION, and the memcpy out of the map + // then walks off its end. The block below rebuilds them at the new + // size, exactly as it built them the first time. mScopeSample.release(); + if (mScopePBO[0]) + { + glDeleteBuffers(2, mScopePBO); + mScopePBO[0] = 0; + mScopePBO[1] = 0; + } if (!mScopeSample.allocate(width, height, GL_RGBA8)) { sScopeCapture = false; From 66e9597527ad6fe72173f5b1aa155cf6b69871c7 Mon Sep 17 00:00:00 2001 From: Zanibar <91260002+taylnos@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:48:27 -0500 Subject: [PATCH 30/32] Cap grouped undo steps, list only loadable LUTs, and reach the LUT folder The last two findings from the branch review, plus the gap the second one made visible: - ALGradeHistory's group path skipped the MAX_DEPTH eviction, because erasing the front mid-group would shift mGroupIndex out from under the transaction still accumulating. endGroup now evicts once the outermost group closes and the index is dead, bringing the cursor down with the stack. A session of nothing but Look applies and section resets stays bounded like any other; test 14 is the group twin of the plain path's cap test. - The LUT combo listed every directory entry, so a readme or a subfolder in colorlut/ became a selectable entry that failed at apply time with only a log line to say why. It now takes regular files with the extensions setupGradingLUT actually loads, iterates with the non-throwing increment, and withholds the user-directory separator when nothing under it survives the filter. - With junk filtered out there was still no way to reach the user's colorlut folder from the viewer at all. The 3D LUT section grows an Open Folder button beside Reset All: creates the folder on first use, then opens it with the same cross-platform helper the poser's preset folder uses. Co-Authored-By: Claude Fable 5 --- indra/newview/alfloaterlightbox.cpp | 71 +++++++++++++++---- indra/newview/alfloaterlightbox.h | 3 + indra/newview/algradehistory.cpp | 19 +++++ .../default/xui/en/panel_lightbox_look.xml | 15 +++- indra/newview/tests/algradehistory_test.cpp | 34 +++++++++ 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 9ce9b80e0b..941985b27d 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -40,6 +40,7 @@ #include "alwhitebalancesolver.h" #include "llagent.h" #include "llenvironment.h" +#include "llfile.h" #include "llnotificationsutil.h" #include "llpanel.h" #include "llpresetsmanager.h" @@ -51,6 +52,7 @@ #include "pipeline.h" #include "rlvactions.h" +#include #include #include #include @@ -174,6 +176,7 @@ ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) mCommitCallbackRegistrar.add("LightBox.RefreshToneCurve", std::bind(&ALFloaterLightBox::refreshToneCurve, this)); mCommitCallbackRegistrar.add("LightBox.CommitSplitToneGraph", std::bind(&ALFloaterLightBox::onCommitSplitToneGraph, this)); mCommitCallbackRegistrar.add("LightBox.PickWhiteBalance", std::bind(&ALFloaterLightBox::onClickWhiteBalancePicker, this)); + mCommitCallbackRegistrar.add("LightBox.OpenLUTFolder", std::bind(&ALFloaterLightBox::onClickOpenLUTFolder, this)); mCommitCallbackRegistrar.add("LightBox.LookSelected", std::bind(&ALFloaterLightBox::onLookSelected, this)); mCommitCallbackRegistrar.add("LightBox.LookSave", std::bind(&ALFloaterLightBox::onClickLookSave, this)); mCommitCallbackRegistrar.add("LightBox.LookSaveAs", std::bind(&ALFloaterLightBox::onClickLookSaveAs, this)); @@ -284,19 +287,36 @@ void ALFloaterLightBox::populateLUTCombo() { LLComboBox* lut_combo = getChild("colorlut_combo"); - auto add_luts_from = [lut_combo](const std::string& dir_name) + // Only what setupGradingLUT can actually load. Anything else in the + // directory -- a readme, a subfolder, a stray .bak -- would become a + // selectable entry that fails at apply time with nothing but a log line + // to say why. getExtension lowercases, so a .CUBE passes here the same + // way it does when the renderer resolves it. + static const char* const LUT_EXTENSIONS[] = { "cube", "tga", "png", "jpg", "jpeg", "bmp", "webp" }; + + // Collected rather than added on the spot, so the caller can see whether + // a directory contributed anything before committing to the separator. + auto collect_luts_from = [](const std::string& dir_name) { + std::vector> found; // stem, filename + std::error_code ec; std::filesystem::path luts_path = fsyspath(dir_name); if (!std::filesystem::is_directory(luts_path, ec) || ec) { - return; + return found; } - for (std::filesystem::directory_iterator lut(luts_path, ec); lut != std::filesystem::directory_iterator(); ++lut) + + // increment(ec), not ++: the throwing increment would carry a + // transient filesystem error out through postBuild. On failure it + // parks the iterator at end instead, which is why ec is looked at + // again once the loop is done. + std::filesystem::directory_iterator end; + for (std::filesystem::directory_iterator lut(luts_path, ec); lut != end && !ec; lut.increment(ec)) { - if (ec) + std::error_code entry_ec; + if (!lut->is_regular_file(entry_ec) || entry_ec) { - LL_WARNS() << "Error reading LUT file in " << dir_name << ": " << ec.message() << LL_ENDL; continue; } #if LL_WINDOWS @@ -306,28 +326,53 @@ void ALFloaterLightBox::populateLUTCombo() std::string lut_stem = lut->path().stem().native(); std::string lut_filename = lut->path().filename().native(); #endif - lut_combo->add(lut_stem, lut_filename); + const std::string exten = gDirUtilp->getExtension(lut_filename); + if (std::find(std::begin(LUT_EXTENSIONS), std::end(LUT_EXTENSIONS), exten) == std::end(LUT_EXTENSIONS)) + { + continue; + } + found.emplace_back(std::move(lut_stem), std::move(lut_filename)); + } + if (ec) + { + LL_WARNS() << "Error reading LUT directory " << dir_name << ": " << ec.message() << LL_ENDL; } + return found; }; // Bundled LUTs first, then user LUTs behind a separator — the same order // the renderer resolves a name in, where the user dir wins. - add_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "colorlut")); + for (const auto& lut : collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "colorlut"))) + { + lut_combo->add(lut.first, lut.second); + } - const std::string& user_luts = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); - std::error_code ec; - std::filesystem::path user_luts_path = fsyspath(user_luts); - if (std::filesystem::is_directory(user_luts_path, ec) && !ec && - !std::filesystem::is_empty(user_luts_path, ec) && !ec) + const auto user_luts = collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut")); + if (!user_luts.empty()) { lut_combo->addSeparator(); - add_luts_from(user_luts); + for (const auto& lut : user_luts) + { + lut_combo->add(lut.first, lut.second); + } } lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); lut_combo->resetDirty(); } +void ALFloaterLightBox::onClickOpenLUTFolder() +{ + // The user's folder, not the bundled one: it is the half of the pair that + // is theirs to put files in, and the one the renderer prefers when a name + // exists in both. Nothing creates it until there is something to put in + // it, which is exactly now -- and LLFile::mkdir is quiet about a + // directory that already exists. + const std::string dir = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); + LLFile::mkdir(dir); + gDirUtilp->openDir(dir); +} + void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) { const std::string& control_name = userdata.asString(); diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 4d95b6663a..7b835447a7 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -93,6 +93,9 @@ class ALFloaterLightBox final : public LLFloater void onClickWhiteBalancePicker(); void onWhiteBalancePicked(const LLColor3& sample); void populateLUTCombo(); + /// Open the user's LUT folder in the platform file browser, creating it + /// first if this is its first use. + void onClickOpenLUTFolder(); void updateTonemapperRows(); /// Freeze the frame about to be presented, and switch the wipe on so the /// grab is visibly a grab. diff --git a/indra/newview/algradehistory.cpp b/indra/newview/algradehistory.cpp index 7dc416c69c..3f6ca3384b 100644 --- a/indra/newview/algradehistory.cpp +++ b/indra/newview/algradehistory.cpp @@ -62,6 +62,10 @@ void ALGradeHistory::record(const std::string& name, const LLSD& before, const L // Inside a group. Extend the group's transaction, unless this control // is already in it -- in which case only the destination moves, so the // group still describes one before and one after per control. + // + // MAX_DEPTH is deliberately not enforced here: evicting the front + // would shift mGroupIndex out from under the group. endGroup does it, + // once the index is dead. if (mGroupIndex >= mStack.size()) { mStack.emplace_back(); @@ -122,6 +126,21 @@ void ALGradeHistory::endGroup() { if (mGroupDepth > 0 && --mGroupDepth == 0) { + // The eviction that record()'s plain path does as it pushes, deferred + // to here, where erasing the front can no longer shift mGroupIndex + // out from under an open group. A group adds at most one transaction + // -- that is its whole point -- so one erase restores the bound. The + // cursor counts applied transactions and the one dropped was applied, + // so it comes down with the stack. + if (mStack.size() > MAX_DEPTH) + { + mStack.erase(mStack.begin()); + if (mCursor > 0) + { + --mCursor; + } + } + // A fresh write after the group starts its own step rather than // coalescing into it. mHaveLast = false; diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml index f7f35ce6a1..0ed717dfad 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -1550,13 +1550,26 @@ Row conventions: function="LightBox.ResetControlDefault" parameter="RenderColorGradeLUTStrength" /> +