diff --git a/indra/llcommon/llsdserialize_xml.cpp b/indra/llcommon/llsdserialize_xml.cpp
index f287bc76b1..e6ec9e1224 100644
--- a/indra/llcommon/llsdserialize_xml.cpp
+++ b/indra/llcommon/llsdserialize_xml.cpp
@@ -549,9 +549,17 @@ void LLSDXMLParser::Impl::parsePart(const char* buf, llssize len)
&& len > 0 )
{
XML_Status status = XML_Parse(mParser, buf, (int)len, 0);
- if (status == XML_STATUS_ERROR)
+ // A short, complete document (e.g. "") may be
+ // wholly contained in this first chunk. Reaching calls
+ // XML_StopParser(false), which makes XML_Parse return XML_STATUS_ERROR
+ // even though the parse succeeded -- mGracefullStop distinguishes that
+ // graceful stop from a real error, matching parse()/parseLines().
+ if (status == XML_STATUS_ERROR && !mGracefullStop)
{
- LL_INFOS() << "Unexpected XML parsing error at start" << LL_ENDL;
+ if (mEmitErrors)
+ {
+ LL_INFOS() << "Unexpected XML parsing error at start" << LL_ENDL;
+ }
}
}
}
diff --git a/indra/llcommon/tests/llsdserialize_test.cpp b/indra/llcommon/tests/llsdserialize_test.cpp
index 639a096b55..ce14a2bf1e 100644
--- a/indra/llcommon/tests/llsdserialize_test.cpp
+++ b/indra/llcommon/tests/llsdserialize_test.cpp
@@ -57,6 +57,7 @@ typedef U32 uint32_t;
#include "../test/namedtempfile.h"
#include "stringize.h"
#include "StringVec.h"
+#include "wrapllerrs.h"
#include
typedef std::function FormatterFunction;
@@ -243,6 +244,33 @@ namespace tut
xml_test("binary", expected);
}
+ template<> template<>
+ void sd_xml_object::test<7>()
+ {
+ // A complete, single-line legacy document (no embedded '\n')
+ // is read entirely into the header buffer by LLSDSerialize::deserialize
+ // and handed to LLSDXMLParser::parsePart(). Reaching within that
+ // first chunk calls XML_StopParser(false), which makes expat report
+ // XML_STATUS_ERROR even though the parse succeeded. parsePart() used to
+ // log a spurious "Unexpected XML parsing error" for that graceful stop.
+ // Empty containers are the common single-line form that triggered it.
+ auto deserialize_no_spurious_error =
+ [](const std::string& xml, const LLSD& expected)
+ {
+ CaptureLog capture(LLError::LEVEL_INFO);
+ std::istringstream input(xml);
+ LLSD parsed;
+ bool ok = LLSDSerialize::deserialize(parsed, input, xml.size());
+ ensure(STRINGIZE("deserialize " << xml << " succeeded"), ok);
+ ensure_equals(STRINGIZE("deserialize " << xml << " value"), parsed, expected);
+ ensure(STRINGIZE("no spurious parse error for " << xml),
+ capture.messageWith("Unexpected XML parsing error", false).empty());
+ };
+
+ deserialize_no_spurious_error("\n", LLSD::emptyMap());
+ deserialize_no_spurious_error("\n", LLSD::emptyArray());
+ }
+
class TestLLSDSerializeData
{
public:
diff --git a/indra/llimage/llpngwrapper.cpp b/indra/llimage/llpngwrapper.cpp
index 79c201b1f4..390bca8bc0 100644
--- a/indra/llimage/llpngwrapper.cpp
+++ b/indra/llimage/llpngwrapper.cpp
@@ -237,7 +237,7 @@ void LLPngWrapper::normalizeImage()
// 1. Expand any palettes
// 2. Convert grayscales to RGB
// 3. Create alpha layer from transparency
- // 4. Ensure 8-bpp for all images
+ // 4. Ensure 8-bpp for all images (16-bit accurately scaled, not truncated)
// 5. Set (or guess) gamma
if (mColorType == PNG_COLOR_TYPE_PALETTE)
@@ -263,7 +263,13 @@ void LLPngWrapper::normalizeImage()
}
else if (mBitDepth == 16)
{
+#ifdef PNG_READ_SCALE_16_TO_8_SUPPORTED
+ // Accurate linear 16->8 reduction (round(v/257)) rather than
+ // png_set_strip_16's biased low-byte truncation (v>>8).
+ png_set_scale_16(mReadPngPtr);
+#else
png_set_strip_16(mReadPngPtr);
+#endif
}
const F64 SCREEN_GAMMA = 2.2;
diff --git a/indra/llprimitive/llmodel.cpp b/indra/llprimitive/llmodel.cpp
index 83d7710f33..3da0d8ba0a 100644
--- a/indra/llprimitive/llmodel.cpp
+++ b/indra/llprimitive/llmodel.cpp
@@ -886,6 +886,12 @@ LLSD LLModel::writeModel(
LLVector3 pos_range = max_pos - min_pos;
+ // O(1) per-vertex weight lookup for the skinning block below; without
+ // it the per-vertex getJointInfluences() scan makes this loop O(V^2)
+ // and freezes the uploader on dense rigged meshes. Built once per
+ // model (empty/cheap when unskinned).
+ JointWeightCache weight_cache(*model[idx]);
+
for (S32 i = 0; i < model[idx]->getNumVolumeFaces(); ++i)
{ //for each face
const LLVolumeFace& face = model[idx]->getVolumeFace(i);
@@ -1044,10 +1050,10 @@ LLSD LLModel::writeModel(
{
LLVector3 pos(face.mPositions[j].getF32ptr());
- weight_list& weights = model[idx]->getJointInfluences(pos);
+ const weight_list& weights = weight_cache.influences(pos);
S32 count = 0;
- for (weight_list::iterator iter = weights.begin(); iter != weights.end(); ++iter)
+ for (weight_list::const_iterator iter = weights.begin(); iter != weights.end(); ++iter)
{
// Note joint index cannot exceed 255.
if (iter->mJointIdx < 255 && iter->mJointIdx >= 0)
@@ -1291,6 +1297,66 @@ LLModel::weight_list& LLModel::getJointInfluences(const LLVector3& pos)
}
}
+LLModel::JointWeightCache::JointWeightCache(LLModel& model)
+ : mModel(model)
+{
+ mCells.reserve(model.mSkinWeights.size());
+ for (const weight_map::value_type& entry : model.mSkinWeights)
+ {
+ mCells[cellKey(entry.first)].push_back(&entry);
+ }
+}
+
+LLModel::JointWeightCache::CellKey LLModel::JointWeightCache::cellKey(const LLVector3& p)
+{
+ return { llfloor(p.mV[VX] / WELD_EPSILON),
+ llfloor(p.mV[VY] / WELD_EPSILON),
+ llfloor(p.mV[VZ] / WELD_EPSILON) };
+}
+
+const LLModel::weight_list& LLModel::JointWeightCache::influences(const LLVector3& pos) const
+{
+ // Match radius == cell size == the weld epsilon, so a key within epsilon of
+ // pos is in pos's cell or an immediate neighbour. Scan the 3x3x3 block,
+ // counting in-epsilon candidates and tracking the closest.
+ const CellKey base = cellKey(pos);
+ const weight_list* best = nullptr;
+ F32 best_dist = WELD_EPSILON;
+ S32 in_epsilon = 0;
+ for (S32 dx = -1; dx <= 1; ++dx)
+ {
+ for (S32 dy = -1; dy <= 1; ++dy)
+ {
+ for (S32 dz = -1; dz <= 1; ++dz)
+ {
+ auto it = mCells.find({ base.x + dx, base.y + dy, base.z + dz });
+ if (it == mCells.end())
+ {
+ continue;
+ }
+ for (const weight_map::value_type* e : it->second)
+ {
+ const F32 d = (e->first - pos).length();
+ if (d < WELD_EPSILON)
+ {
+ ++in_epsilon;
+ if (d < best_dist)
+ {
+ best_dist = d;
+ best = &e->second;
+ }
+ }
+ }
+ }
+ }
+ }
+ // Defer to the full search unless we found exactly one in-epsilon match.
+ // getJointInfluences() returns the FIRST weld-epsilon match in map order, so
+ // on a miss (closest-point fallback) or an ambiguous tie (multiple keys
+ // within epsilon) we mirror it exactly instead of guessing the closest.
+ return (best && in_epsilon == 1) ? *best : mModel.getJointInfluences(pos);
+}
+
void LLModel::setConvexHullDecomposition(
const LLModel::convex_hull_decomposition& decomp, const std::vector& decomp_mesh)
{
diff --git a/indra/llprimitive/llmodel.h b/indra/llprimitive/llmodel.h
index d624b6dc7a..9cde071de1 100644
--- a/indra/llprimitive/llmodel.h
+++ b/indra/llprimitive/llmodel.h
@@ -32,6 +32,7 @@
#include "v4math.h"
#include "m4math.h"
#include
+#include
class daeElement;
class domMesh;
@@ -288,6 +289,42 @@ class alignas(16) LLModel : public LLVolume
//get list of weight influences closest to given position
weight_list& getJointInfluences(const LLVector3& pos);
+ // O(1) accelerator for getJointInfluences(). That function linearly scans
+ // mSkinWeights, so calling it once per vertex (writeModel, LOD vertex-buffer
+ // fill, local-mesh preview) is O(V^2) and stalls the main thread for seconds
+ // on a dense rigged mesh. Build one of these once before a per-vertex loop,
+ // then call influences() per vertex for an O(1) lookup -- making the weight
+ // pass O(V). It snapshots pointers into the model's current mSkinWeights, so
+ // construct it after the weights are final and do not mutate mSkinWeights
+ // while it is alive. A position with no key within the weld epsilon falls
+ // back to getJointInfluences() (preserving its exact-find / closest-point
+ // path), so results are identical to calling that function directly.
+ class JointWeightCache
+ {
+ public:
+ explicit JointWeightCache(LLModel& model);
+ const weight_list& influences(const LLVector3& pos) const;
+
+ private:
+ static constexpr F32 WELD_EPSILON = 1e-5f; // == jointPositionalLookup()'s tolerance
+ struct CellKey
+ {
+ S32 x, y, z;
+ bool operator==(const CellKey& o) const { return x == o.x && y == o.y && z == o.z; }
+ };
+ struct CellHash
+ {
+ size_t operator()(const CellKey& k) const
+ {
+ return (size_t)(((U32)k.x * 73856093u) ^ ((U32)k.y * 19349663u) ^ ((U32)k.z * 83492791u));
+ }
+ };
+ static CellKey cellKey(const LLVector3& p);
+
+ LLModel& mModel;
+ std::unordered_map, CellHash> mCells;
+ };
+
LLMeshSkinInfo mSkinInfo;
std::string mRequestedLabel; // name requested in UI, if any.
diff --git a/indra/llui/llconsole.cpp b/indra/llui/llconsole.cpp
index ca512a9883..835a351df9 100644
--- a/indra/llui/llconsole.cpp
+++ b/indra/llui/llconsole.cpp
@@ -189,7 +189,7 @@ void LLConsole::draw()
// draw remaining lines
F32 y_pos = 0.f;
- LLUIImagePtr imagep = LLUI::getUIImage("transparent");
+ LLUIImagePtr imagep = LLUI::getUIImage("transparent.j2c");
static LLCachedControl console_bg_opacity(*LLUI::getInstance()->mSettingGroups["config"], "ConsoleBackgroundOpacity", 0.7f);
F32 console_opacity = llclamp(console_bg_opacity(), 0.f, 1.f);
diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt
index 691831de41..db3f7aa9cf 100644
--- a/indra/newview/CMakeLists.txt
+++ b/indra/newview/CMakeLists.txt
@@ -380,6 +380,7 @@ set(viewer_SOURCE_FILES
llfloaterlandholdings.cpp
llfloaterlinkreplace.cpp
llfloaterloadprefpreset.cpp
+ llfloaterlocalassets.cpp
llfloatermarketplacelistings.cpp
llfloatermap.cpp
llfloatermediasettings.cpp
@@ -513,6 +514,9 @@ set(viewer_SOURCE_FILES
lllistview.cpp
lllocalbitmaps.cpp
lllocalgltfmaterials.cpp
+ lllocalmesh.cpp
+ lllocalanim.cpp
+ lllocalassetpaths.cpp
lllocationhistory.cpp
lllocationinputctrl.cpp
lllogchat.cpp
@@ -1144,6 +1148,7 @@ set(viewer_HEADER_FILES
llfloaterlandholdings.h
llfloaterlinkreplace.h
llfloaterloadprefpreset.h
+ llfloaterlocalassets.h
llfloatermap.h
llfloatermarketplace.h
llfloatermarketplacelistings.h
@@ -1276,6 +1281,9 @@ set(viewer_HEADER_FILES
lllistview.h
lllocalbitmaps.h
lllocalgltfmaterials.h
+ lllocalmesh.h
+ lllocalanim.h
+ lllocalassetpaths.h
lllocationhistory.h
lllocationinputctrl.h
lllogchat.h
diff --git a/indra/newview/app_settings/commands.xml b/indra/newview/app_settings/commands.xml
index c1d1b843b4..63b9e13665 100644
--- a/indra/newview/app_settings/commands.xml
+++ b/indra/newview/app_settings/commands.xml
@@ -127,6 +127,16 @@
is_running_function="Floater.IsOpen"
is_running_parameters="inventory"
/>
+
the Model upload floater
+#include "llmodel.h" // LLModel::LOD_HIGH
+
+#include "llagentcamera.h"
+#include "llcontrolavatar.h"
+#include "llgltfmateriallist.h" // LLGLTFMaterialList::flushUpdates (apply local material)
+#include "lllistcontextmenu.h"
+#include "lllocalanim.h"
+#include "lllocalassetpaths.h"
+#include "lllocalbitmaps.h"
+#include "lllocalgltfmaterials.h"
+#include "lllocalmesh.h"
+#include "llselectmgr.h"
+#include "llviewerjointattachment.h"
+#include "llviewermenu.h" // get_selected_animesh_control_avatar
+#include "llviewerobject.h"
+#include "llviewertexture.h" // LLViewerTextureManager::getFetchedTexture (apply local texture)
+#include "llvoavatarself.h" // gAgentAvatarp, isAgentAvatarValid, mAttachmentPoints
+
+#include
+
+// ============================================================================
+// LLPanelLocalAssetBase
+//
+// Shared scroll-list behaviour for the asset tabs. The list shows both decoded
+// units (fed by the backing manager) and saved-but-undecoded file paths (from
+// LLLocalAssetPaths, dimmed). Files decode lazily: double-clicking an undecoded
+// row -- or an action that needs it -- loads it. Each concrete tab plugs in its
+// manager via a small set of virtuals; everything else lives here.
+// ============================================================================
+namespace
+{
+
+class LLPanelLocalAssetBase : public LLPanel
+{
+public:
+ bool postBuild() override;
+
+ // Rebuild the visible list (decoded units + dimmed undecoded saved paths).
+ void refresh() override;
+
+ // Decode + add a file into this tab's backing manager (used by the floater's
+ // OS drag-and-drop routing). Public wrapper over the protected loadPath(); the
+ // backing manager dedups, so a file that's already loaded is never added twice.
+ void loadFile(const std::string& path) { loadPath(path); }
+
+protected:
+ // Backing-manager hooks, implemented per asset type.
+ virtual void feedList() = 0; // decoded units -> rows
+ virtual void delUnit(const LLUUID& tracking_id) = 0; // unload a decoded unit
+ virtual void loadPath(const std::string& path) = 0; // decode a file (lazy / add)
+ virtual LLUUID unitForPath(const std::string& path) const = 0; // decoded? -> tracking id
+ virtual std::string pathForUnit(const LLUUID& tracking_id) const = 0; // tracking id -> path
+ // All decoded unit ids backing a saved path. Default: the single unit unitForPath()
+ // resolves. The GLTF Materials panel overrides this -- one .gltf file can decode to
+ // several material units, and removing its row must unload all of them.
+ virtual void unitsForPath(const std::string& path, std::vector& out) const
+ {
+ const LLUUID id = unitForPath(path);
+ if (id.notNull()) { out.push_back(id); }
+ }
+ virtual std::string iconName() const = 0; // row icon for this type
+ virtual LLLocalAssetPaths::EType assetType() const = 0;
+ virtual LLFilePicker::ELoadFilter getLoadFilter() const = 0;
+ // Subscribe to the backing manager's "units changed" signal for reactive refresh.
+ virtual boost::signals2::connection connectChanged(const std::function& cb) = 0;
+ // Send the file at `path` through the viewer's standard upload flow for this type.
+ virtual void doUpload(const std::string& path) = 0;
+
+ // Optional per-type extra buttons (Rez/Attach, Play/Stop, ...). Shown and wired
+ // by overrides; the base keeps them hidden.
+ virtual void initExtraButtons() {}
+ virtual void updateExtraButtons(bool /*has_selection*/) {}
+
+ // Placeholder shown over the list while it's empty (per asset type).
+ virtual std::string emptyHint() const { return LLStringUtil::null; }
+
+ LLUUID getSelectedID() const; // null if the selection is undecoded
+ std::vector getSelectedIDs() const; // decoded selections only
+ std::string getSelectedPath() const; // decoded or undecoded
+
+ LLScrollListCtrl* mList { nullptr };
+ LLButton* mAddBtn { nullptr };
+ LLButton* mUnloadBtn { nullptr }; // free the asset, keep the saved path (dimmed row)
+ LLButton* mRemoveBtn { nullptr }; // forget the file entirely (row disappears)
+ LLButton* mUploadBtn { nullptr }; // upload the selected file to Second Life
+
+private:
+ void appendUnloaded();
+ void selectByPath(const std::string& path);
+ bool anySelectedMeshOwned() const; // a model-loaded (read-only) row is selected
+ void onAddBtn();
+ void onUnloadBtn();
+ void onRemoveBtn();
+ void onUploadBtn();
+ void onDoubleClick();
+ void onSelectionChange();
+ static void onFilesPicked(const std::vector& filenames,
+ LLHandle handle);
+
+ boost::signals2::scoped_connection mChangedConn;
+};
+
+bool LLPanelLocalAssetBase::postBuild()
+{
+ mList = getChild("l_name_list");
+ mAddBtn = getChild("add_btn");
+ mUnloadBtn = getChild("unload_btn");
+ mRemoveBtn = getChild("remove_btn");
+ mUploadBtn = getChild("upload_btn");
+
+ mList->setCommitOnSelectionChange(true);
+ mList->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onSelectionChange, this));
+ mList->setDoubleClickCallback(boost::bind(&LLPanelLocalAssetBase::onDoubleClick, this));
+ mAddBtn->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onAddBtn, this));
+ mUnloadBtn->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onUnloadBtn, this));
+ mRemoveBtn->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onRemoveBtn, this));
+ mUploadBtn->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onUploadBtn, this));
+
+ // Reactive refresh: the backing manager signals us on any unit change (decode,
+ // remove, and for mesh spawn/derez), whoever made it -- us, the texture picker,
+ // an in-world Delete. scoped_connection drops on panel teardown.
+ mChangedConn = connectChanged(boost::bind(&LLPanelLocalAssetBase::refresh, this));
+
+ initExtraButtons();
+ refresh();
+ return true;
+}
+
+void LLPanelLocalAssetBase::refresh()
+{
+ if (!mList)
+ {
+ return;
+ }
+ const std::string prev = getSelectedPath();
+ mList->clearRows();
+ feedList(); // decoded units (with their icons / mesh bold etc.)
+ appendUnloaded(); // saved-but-undecoded paths, dimmed
+ selectByPath(prev);
+ onSelectionChange();
+ // Hint over an empty list (LLScrollListCtrl shows the comment only when empty).
+ mList->setCommentText(mList->getItemCount() == 0 ? emptyHint() : LLStringUtil::null);
+}
+
+void LLPanelLocalAssetBase::appendUnloaded()
+{
+ const LLSD paths = LLLocalAssetPaths::getInstance()->getPaths(assetType());
+ const std::string icon = iconName();
+ for (LLSD::array_const_iterator it = paths.beginArray(); it != paths.endArray(); ++it)
+ {
+ const std::string path = it->asString();
+ if (unitForPath(path).notNull())
+ {
+ continue; // already decoded -> shown by feedList()
+ }
+ LLSD element;
+ element["columns"][0]["column"] = "icon";
+ element["columns"][0]["type"] = "icon";
+ element["columns"][0]["value"] = icon;
+
+ element["columns"][1]["column"] = "unit_name";
+ element["columns"][1]["type"] = "text";
+ element["columns"][1]["value"] = gDirUtilp->getBaseFileName(path, true);
+ element["columns"][1]["font"]["style"] = "ITALIC"; // dimmed: not loaded yet
+
+ LLSD data;
+ data["path"] = path; // no "id" -> undecoded
+ element["value"] = data;
+
+ mList->addElement(element);
+ }
+}
+
+std::string LLPanelLocalAssetBase::getSelectedPath() const
+{
+ if (mList)
+ {
+ if (LLScrollListItem* item = mList->getFirstSelected())
+ {
+ const LLSD v = item->getValue();
+ if (v.has("path"))
+ {
+ return v["path"].asString();
+ }
+ const LLUUID id = v["id"].asUUID();
+ if (id.notNull())
+ {
+ return pathForUnit(id);
+ }
+ }
+ }
+ return std::string();
+}
+
+void LLPanelLocalAssetBase::selectByPath(const std::string& path)
+{
+ if (!mList || path.empty())
+ {
+ return;
+ }
+ const std::vector& items = mList->getAllData();
+ for (size_t i = 0; i < items.size(); ++i)
+ {
+ if (!items[i])
+ {
+ continue;
+ }
+ const LLSD v = items[i]->getValue();
+ const std::string rowpath = v.has("path") ? v["path"].asString() : pathForUnit(v["id"].asUUID());
+ if (rowpath == path)
+ {
+ mList->selectNthItem((S32)i);
+ break;
+ }
+ }
+}
+
+LLUUID LLPanelLocalAssetBase::getSelectedID() const
+{
+ if (mList)
+ {
+ if (LLScrollListItem* item = mList->getFirstSelected())
+ {
+ return item->getValue()["id"].asUUID();
+ }
+ }
+ return LLUUID::null;
+}
+
+std::vector LLPanelLocalAssetBase::getSelectedIDs() const
+{
+ std::vector ids;
+ if (mList)
+ {
+ for (LLScrollListItem* item : mList->getAllSelected())
+ {
+ if (item)
+ {
+ const LLUUID id = item->getValue()["id"].asUUID();
+ if (id.notNull())
+ {
+ ids.push_back(id);
+ }
+ }
+ }
+ }
+ return ids;
+}
+
+bool LLPanelLocalAssetBase::anySelectedMeshOwned() const
+{
+ if (mList)
+ {
+ for (LLScrollListItem* item : mList->getAllSelected())
+ {
+ if (item && item->getValue()["mesh_owned"].asBoolean())
+ {
+ return true; // a model-loaded (read-only) row is in the selection
+ }
+ }
+ }
+ return false;
+}
+
+void LLPanelLocalAssetBase::onSelectionChange()
+{
+ const bool has_selection = mList && !mList->getAllSelected().empty();
+ // Unload only makes sense for a decoded row (an undecoded one is already
+ // unloaded); Remove forgets the saved path, so it works on either.
+ const bool has_decoded = !getSelectedIDs().empty();
+ // Model-loaded (mesh-owned) rows are read-only -- they belong to the mesh that
+ // imported them, so block Unload/Remove while one is selected.
+ const bool read_only = anySelectedMeshOwned();
+ if (mUnloadBtn)
+ {
+ mUnloadBtn->setEnabled(has_decoded && !read_only);
+ }
+ if (mRemoveBtn)
+ {
+ mRemoveBtn->setEnabled(has_selection && !read_only);
+ }
+ if (mUploadBtn)
+ {
+ // Upload acts on the selected file (decoded or not), so a row is all it needs.
+ mUploadBtn->setEnabled(has_selection);
+ }
+ updateExtraButtons(has_selection);
+}
+
+void LLPanelLocalAssetBase::onDoubleClick()
+{
+ // Double-clicking a dimmed (undecoded) row loads it on demand.
+ const std::string path = getSelectedPath();
+ if (!path.empty() && unitForPath(path).isNull())
+ {
+ loadPath(path); // decode -> manager signal -> refresh()
+ }
+}
+
+void LLPanelLocalAssetBase::onAddBtn()
+{
+ LLHandle handle = getDerivedHandle();
+ LLFilePickerReplyThread::startPicker(
+ boost::bind(&LLPanelLocalAssetBase::onFilesPicked, _1, handle),
+ getLoadFilter(), true);
+}
+
+void LLPanelLocalAssetBase::onUnloadBtn()
+{
+ if (!mList)
+ {
+ return;
+ }
+ // Free each selected decoded unit but keep its saved path, so the row stays in
+ // the list as a dimmed, reloadable entry. For a mesh this also derezzes its
+ // in-world copies. delUnit() fires the manager signal, which refreshes us; an
+ // undecoded selection has no unit to unload. Snapshot first -- delUnit() frees
+ // the LLScrollListItems we'd otherwise be iterating.
+ const std::vector ids = getSelectedIDs();
+ for (const LLUUID& id : ids)
+ {
+ delUnit(id);
+ }
+}
+
+void LLPanelLocalAssetBase::onUploadBtn()
+{
+ // Upload the selected file through the standard per-type upload flow (with its
+ // L$ cost confirmation). Works on a decoded or saved-but-undecoded row -- it's
+ // the file on disk we upload, not the in-memory preview.
+ const std::string path = getSelectedPath();
+ if (!path.empty())
+ {
+ doUpload(path);
+ }
+}
+
+void LLPanelLocalAssetBase::onRemoveBtn()
+{
+ if (!mList)
+ {
+ return;
+ }
+ // Snapshot (path, id) first: removePath()/delUnit() mutate state and free the
+ // LLScrollListItems we'd otherwise be iterating.
+ std::vector > selected;
+ for (LLScrollListItem* item : mList->getAllSelected())
+ {
+ if (!item)
+ {
+ continue;
+ }
+ const LLSD v = item->getValue();
+ const LLUUID id = v["id"].asUUID();
+ const std::string path = v.has("path") ? v["path"].asString() : pathForUnit(id);
+ selected.emplace_back(path, id);
+ }
+
+ for (const auto& entry : selected)
+ {
+ if (!entry.first.empty())
+ {
+ LLLocalAssetPaths::getInstance()->removePath(assetType(), entry.first); // forget the path
+ }
+ // Unload every decoded unit backing this row. For a multi-material glTF file
+ // that's all of the file's material units, not just the selected row.
+ std::vector ids;
+ if (!entry.first.empty())
+ {
+ unitsForPath(entry.first, ids);
+ }
+ if (ids.empty() && entry.second.notNull())
+ {
+ ids.push_back(entry.second);
+ }
+ for (const LLUUID& id : ids)
+ {
+ delUnit(id); // unload the decoded unit (fires the manager signal)
+ }
+ }
+ refresh(); // removePath() alone (undecoded rows) doesn't fire a manager signal
+}
+
+// static
+void LLPanelLocalAssetBase::onFilesPicked(const std::vector& filenames,
+ LLHandle handle)
+{
+ // The picker runs on its own thread and posts back here; the panel may have
+ // been torn down (floater closed) in the meantime.
+ if (handle.isDead() || filenames.empty())
+ {
+ return;
+ }
+ LLPanelLocalAssetBase* self = handle.get();
+ for (const std::string& filename : filenames)
+ {
+ if (!filename.empty())
+ {
+ // Decode now (the user just chose it); the manager signal both refreshes
+ // us and records the path in LLLocalAssetPaths for persistence. The
+ // manager dedups, so re-picking a loaded file won't add it twice.
+ self->loadPath(filename);
+ }
+ }
+}
+
+// ============================================================================
+// Mesh tab -- Rez/Derez, an attach-point combo + Attach, Select, joint toggle.
+// ============================================================================
+class LLPanelLocalMesh final : public LLPanelLocalAssetBase
+{
+public:
+ ~LLPanelLocalMesh() override;
+
+ // Actions shared by the side buttons and the right-click row menu (decoded units).
+ void doSpawn(const LLUUID& tracking_id);
+ void doAttach(const LLUUID& tracking_id, S32 attach_point);
+ void doDetach(const LLUUID& tracking_id);
+ void doUnload(const LLUUID& tracking_id); // free + derez copies, keep the file in the list
+ void doRemove(const LLUUID& tracking_id); // forget the file entirely
+ void menuAttach(const LLUUID& tracking_id, const LLSD& point) { doAttach(tracking_id, point.asInteger()); }
+ void doSelect(const LLUUID& tracking_id);
+ void doDerez(const LLUUID& tracking_id);
+ bool isUnitAttached(const LLUUID& tracking_id) const;
+ bool isUnitSpawned(const LLUUID& tracking_id) const;
+
+protected:
+ void feedList() override
+ {
+ LLLocalMeshMgr::getInstance()->feedScrollList(mList);
+ }
+ void delUnit(const LLUUID& tracking_id) override
+ {
+ LLLocalMeshMgr::getInstance()->delUnit(tracking_id);
+ }
+ void loadPath(const std::string& path) override
+ {
+ // Decode with the joint-position-override flag the artist saved for this file.
+ LLLocalMeshMgr::getInstance()->addUnit(path, LLLocalAssetPaths::getInstance()->getMeshJoints(path));
+ }
+ LLUUID unitForPath(const std::string& path) const override
+ {
+ return LLLocalMeshMgr::getInstance()->getUnitID(path);
+ }
+ std::string pathForUnit(const LLUUID& tracking_id) const override
+ {
+ return LLLocalMeshMgr::getInstance()->getFilename(tracking_id);
+ }
+ std::string iconName() const override
+ {
+ return LLInventoryIcon::getIconName(LLAssetType::AT_OBJECT, LLInventoryType::IT_OBJECT);
+ }
+ LLLocalAssetPaths::EType assetType() const override { return LLLocalAssetPaths::TYPE_MESH; }
+ LLFilePicker::ELoadFilter getLoadFilter() const override { return LLFilePicker::FFLOAD_MODEL; }
+ std::string emptyHint() const override { return getString("empty_hint_mesh"); }
+ boost::signals2::connection connectChanged(const std::function& cb) override
+ {
+ return LLLocalMeshMgr::getInstance()->setUnitsChangedCallback(cb);
+ }
+ void doUpload(const std::string& path) override
+ {
+ // Hand the file to the standard Model upload floater (LOD/physics/cost).
+ if (LLFloaterModelPreview* fmp =
+ dynamic_cast(LLFloaterReg::showInstance("upload_model")))
+ {
+ fmp->loadModel(LLModel::LOD_HIGH, path);
+ }
+ }
+
+ void initExtraButtons() override;
+ void updateExtraButtons(bool has_selection) override;
+
+private:
+ void onRez();
+ void onAttach();
+ void onSelect();
+ void onToggleJoints();
+ void onRowRightClick(S32 x, S32 y);
+ void populateAttachPoints();
+ void refreshActionButtons();
+ S32 getComboAttachPoint() const;
+
+ LLButton* mRezBtn { nullptr };
+ LLButton* mSelectBtn { nullptr };
+ LLButton* mAttachBtn { nullptr };
+ LLComboBox* mAttachCombo { nullptr };
+ LLCheckBoxCtrl* mJointsCheck { nullptr };
+ LLListContextMenu* mRowMenu { nullptr };
+};
+
+// Right-click menu for a decoded mesh row: Rez, Attach To > (points), Detach, Delete.
+// Built in code (the attach-point submenu is per-avatar dynamic) but wired the
+// blessed way -- a ScopedRegistrar binds the menu's function names to this panel.
+class LLLocalMeshRowMenu final : public LLListContextMenu
+{
+public:
+ explicit LLLocalMeshRowMenu(LLPanelLocalMesh* panel) : mPanel(panel) {}
+
+protected:
+ LLContextMenu* createMenu() override
+ {
+ LLUICtrl::CommitCallbackRegistry::ScopedRegistrar reg;
+ LLUICtrl::EnableCallbackRegistry::ScopedRegistrar ereg;
+ const LLUUID id = mUUIDs.empty() ? LLUUID::null : mUUIDs.front();
+
+ reg.add("LocalMesh.Spawn", boost::bind(&LLPanelLocalMesh::doSpawn, mPanel, id));
+ reg.add("LocalMesh.Derez", boost::bind(&LLPanelLocalMesh::doDerez, mPanel, id));
+ reg.add("LocalMesh.Select", boost::bind(&LLPanelLocalMesh::doSelect, mPanel, id));
+ reg.add("LocalMesh.Attach", boost::bind(&LLPanelLocalMesh::menuAttach, mPanel, id, _2));
+ reg.add("LocalMesh.Detach", boost::bind(&LLPanelLocalMesh::doDetach, mPanel, id));
+ reg.add("LocalMesh.Unload", boost::bind(&LLPanelLocalMesh::doUnload, mPanel, id));
+ reg.add("LocalMesh.Remove", boost::bind(&LLPanelLocalMesh::doRemove, mPanel, id));
+ ereg.add("LocalMesh.IsAttached", boost::bind(&LLPanelLocalMesh::isUnitAttached, mPanel, id));
+ ereg.add("LocalMesh.IsSpawned", boost::bind(&LLPanelLocalMesh::isUnitSpawned, mPanel, id));
+
+ LLContextMenu* menu = createFromFile("menu_local_mesh.xml");
+ if (!menu)
+ {
+ return nullptr;
+ }
+
+ // Fill the (empty in XUI) "Attach To" submenu with this avatar's points,
+ // ordered by attachment-point id -- the same id render order sorts by.
+ LLMenuGL* submenu = menu->findChildMenuByName("attach_to", true);
+ if (submenu && isAgentAvatarValid())
+ {
+ for (const auto& pair : gAgentAvatarp->mAttachmentPoints)
+ {
+ LLViewerJointAttachment* attachment = pair.second;
+ if (!attachment || attachment->getIsHUDAttachment())
+ {
+ continue;
+ }
+ LLMenuItemCallGL::Params p;
+ const std::string label = llformat("%s (%d)", attachment->getName().c_str(), pair.first);
+ p.name = label;
+ p.label = label;
+ p.on_click.function_name = "LocalMesh.Attach";
+ p.on_click.parameter = (S32)pair.first;
+ submenu->addChild(LLUICtrlFactory::create(p));
+ }
+ }
+ return menu;
+ }
+
+private:
+ LLPanelLocalMesh* mPanel;
+};
+
+LLPanelLocalMesh::~LLPanelLocalMesh()
+{
+ delete mRowMenu;
+}
+
+void LLPanelLocalMesh::initExtraButtons()
+{
+ // Mesh rows carry a Status column (rezzed / attached + point). The shared XUI
+ // gives every tab just icon + name, so rebuild this one list's columns to add it.
+ mList->clearColumns();
+ {
+ LLScrollListColumn::Params c;
+ c.name = "icon";
+ c.width.pixel_width = 20;
+ mList->addColumn(c);
+ }
+ {
+ LLScrollListColumn::Params c;
+ c.name = "unit_name";
+ c.header.label = getString("col_name");
+ // Fill the space LEFT OVER by the fixed icon/status columns. relative_width
+ // would instead claim that fraction of the WHOLE list width, pushing the
+ // fixed Status column off the right edge (invisible).
+ c.width.dynamic_width = true;
+ mList->addColumn(c);
+ }
+ {
+ LLScrollListColumn::Params c;
+ c.name = "status";
+ c.header.label = getString("col_status");
+ c.width.pixel_width = 120;
+ mList->addColumn(c);
+ }
+
+ mRezBtn = getChild("spawn_btn");
+ mSelectBtn = getChild("select_btn");
+ mAttachBtn = getChild("attach_btn");
+ mAttachCombo = getChild("attach_point_combo");
+ mJointsCheck = getChild("include_joints_check");
+
+ mRezBtn->setVisible(true);
+ mSelectBtn->setVisible(true);
+ mAttachBtn->setVisible(true);
+ mAttachCombo->setVisible(true);
+ mJointsCheck->setVisible(true);
+
+ mRezBtn->setCommitCallback(boost::bind(&LLPanelLocalMesh::onRez, this));
+ mRezBtn->setToolTip(getString("rez_tooltip")); // spawn_btn slot is repurposed per tab
+ mSelectBtn->setCommitCallback(boost::bind(&LLPanelLocalMesh::onSelect, this));
+ mAttachBtn->setCommitCallback(boost::bind(&LLPanelLocalMesh::onAttach, this));
+ mJointsCheck->setCommitCallback(boost::bind(&LLPanelLocalMesh::onToggleJoints, this));
+
+ mRowMenu = new LLLocalMeshRowMenu(this);
+ mList->setRightMouseDownCallback(boost::bind(&LLPanelLocalMesh::onRowRightClick, this, _2, _3));
+
+ populateAttachPoints();
+}
+
+void LLPanelLocalMesh::populateAttachPoints()
+{
+ // The floater can outlive a logout or be opened before login; (re)fill the
+ // combo the first time the agent avatar is available.
+ if (!mAttachCombo || !isAgentAvatarValid() || mAttachCombo->getItemCount() > 0)
+ {
+ return;
+ }
+
+ // mAttachmentPoints is keyed (and thus iterated) by attachment-point id, the
+ // same id render order is sorted by.
+ for (const auto& pair : gAgentAvatarp->mAttachmentPoints)
+ {
+ LLViewerJointAttachment* attachment = pair.second;
+ if (!attachment || attachment->getIsHUDAttachment())
+ {
+ continue;
+ }
+ const std::string label = llformat("%s (%d)", attachment->getName().c_str(), pair.first);
+ mAttachCombo->add(label, LLSD((S32)pair.first));
+ }
+ mAttachCombo->selectByValue(LLSD((S32)1)); // default to chest
+}
+
+void LLPanelLocalMesh::updateExtraButtons(bool has_selection)
+{
+ populateAttachPoints();
+ const LLUUID id = getSelectedID(); // null when an undecoded row is selected
+ const bool loaded = id.notNull();
+ // Attach works on an undecoded row too -- it loads then attaches -- so enable on
+ // any selection, not just a decoded one.
+ const bool can_attach = has_selection && isAgentAvatarValid() &&
+ mAttachCombo && mAttachCombo->getItemCount() > 0;
+ if (mAttachBtn) { mAttachBtn->setEnabled(can_attach); }
+ if (mAttachCombo) { mAttachCombo->setEnabled(can_attach); }
+ if (mJointsCheck)
+ {
+ mJointsCheck->setEnabled(loaded);
+ mJointsCheck->set(loaded && LLLocalMeshMgr::getInstance()->getIncludeJointPositions(id));
+ }
+ refreshActionButtons();
+}
+
+void LLPanelLocalMesh::refreshActionButtons()
+{
+ const bool has_selection = mList && !mList->getAllSelected().empty();
+ const LLUUID id = getSelectedID();
+ const bool spawned = id.notNull() && LLLocalMeshMgr::getInstance()->getSpawnedRoot(id) != nullptr;
+ if (mRezBtn)
+ {
+ // Rez always spawns a NEW copy -- it no longer toggles to Derez. Copies are
+ // managed per-instance (Spawned tab / in-world Delete) or via Derez All.
+ mRezBtn->setEnabled(has_selection);
+ mRezBtn->setLabel(getString("rez_label"));
+ }
+ if (mSelectBtn)
+ {
+ mSelectBtn->setEnabled(spawned);
+ }
+}
+
+S32 LLPanelLocalMesh::getComboAttachPoint() const
+{
+ return mAttachCombo ? mAttachCombo->getValue().asInteger() : 0;
+}
+
+void LLPanelLocalMesh::onRez()
+{
+ const LLUUID id = getSelectedID();
+ if (id.notNull())
+ {
+ doSpawn(id); // always rez a new copy
+ return;
+ }
+ // Undecoded: load it and rez once it finishes (addAndSpawn handles the async load).
+ const std::string path = getSelectedPath();
+ if (!path.empty())
+ {
+ LLLocalMeshMgr::getInstance()->addAndSpawn(std::vector(1, path));
+ }
+}
+
+void LLPanelLocalMesh::onAttach()
+{
+ const LLUUID id = getSelectedID();
+ if (id.notNull())
+ {
+ doAttach(id, getComboAttachPoint());
+ return;
+ }
+ // Undecoded row: load it and attach once it finishes loading (mirrors how Rez
+ // handles an undecoded row via addAndSpawn).
+ const std::string path = getSelectedPath();
+ if (!path.empty())
+ {
+ LLLocalMeshMgr::getInstance()->addAndAttach(path, getComboAttachPoint());
+ }
+}
+
+void LLPanelLocalMesh::onSelect()
+{
+ doSelect(getSelectedID());
+}
+
+void LLPanelLocalMesh::onToggleJoints()
+{
+ if (!mJointsCheck)
+ {
+ return;
+ }
+ const bool include = mJointsCheck->get();
+ for (const LLUUID& id : getSelectedIDs())
+ {
+ LLLocalMeshMgr::getInstance()->setIncludeJointPositions(id, include);
+ }
+}
+
+void LLPanelLocalMesh::onRowRightClick(S32 x, S32 y)
+{
+ if (!mList || !mRowMenu)
+ {
+ return;
+ }
+ mList->selectItemAt(x, y, MASK_NONE); // also refreshes the side buttons
+ const LLUUID id = getSelectedID();
+ if (id.isNull())
+ {
+ return; // undecoded row: use Rez (auto-loads) or double-click to load first
+ }
+ uuid_vec_t ids;
+ ids.push_back(id);
+ mRowMenu->show(mList, ids, x, y);
+}
+
+void LLPanelLocalMesh::doSpawn(const LLUUID& tracking_id)
+{
+ if (tracking_id.notNull())
+ {
+ LLLocalMeshMgr::getInstance()->spawnInWorld(tracking_id); // units-changed signal -> refresh()
+ }
+}
+
+void LLPanelLocalMesh::doDerez(const LLUUID& tracking_id)
+{
+ if (tracking_id.notNull())
+ {
+ LLLocalMeshMgr::getInstance()->despawn(tracking_id); // out of world, keep the file; signal -> refresh()
+ }
+}
+
+void LLPanelLocalMesh::doAttach(const LLUUID& tracking_id, S32 attach_point)
+{
+ if (tracking_id.isNull())
+ {
+ return;
+ }
+ LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance();
+ // Attach always wears a fresh copy (Rez and Attach both spawn a new instance now);
+ // wearing a specific already-rezzed copy is a per-instance op on the Spawned tab.
+ if (LLViewerObject* root = mgr->spawnInWorld(tracking_id))
+ {
+ mgr->attachPreviewToAvatar(root, attach_point); // the spawn signal refreshes us
+ }
+}
+
+void LLPanelLocalMesh::doDetach(const LLUUID& tracking_id)
+{
+ LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance();
+ if (LLViewerObject* root = mgr->getSpawnedRoot(tracking_id))
+ {
+ mgr->detachPreviewFromAvatar(root);
+ }
+}
+
+void LLPanelLocalMesh::doUnload(const LLUUID& tracking_id)
+{
+ if (tracking_id.notNull())
+ {
+ // Free the unit (and derez its in-world copies) but keep the saved path, so
+ // the mesh stays in the list as a dimmed, reloadable entry.
+ delUnit(tracking_id); // units-changed signal -> refresh()
+ }
+}
+
+void LLPanelLocalMesh::doRemove(const LLUUID& tracking_id)
+{
+ if (tracking_id.notNull())
+ {
+ LLLocalAssetPaths::getInstance()->removePath(LLLocalAssetPaths::TYPE_MESH,
+ pathForUnit(tracking_id));
+ delUnit(tracking_id); // units-changed signal -> refresh()
+ }
+}
+
+bool LLPanelLocalMesh::isUnitAttached(const LLUUID& tracking_id) const
+{
+ LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance();
+ LLViewerObject* root = mgr->getSpawnedRoot(tracking_id);
+ return root && mgr->isPreviewAttached(root);
+}
+
+bool LLPanelLocalMesh::isUnitSpawned(const LLUUID& tracking_id) const
+{
+ return LLLocalMeshMgr::getInstance()->getSpawnedRoot(tracking_id) != nullptr;
+}
+
+void LLPanelLocalMesh::doSelect(const LLUUID& tracking_id)
+{
+ LLViewerObject* root = LLLocalMeshMgr::getInstance()->getSpawnedRoot(tracking_id);
+ if (!root)
+ {
+ return;
+ }
+ // Select the linkset and open Build to edit it. Deliberately does NOT move the
+ // camera (artists found Select yanking the view jarring); framing a copy is the
+ // separate Focus Camera action on the Spawned tab.
+ LLSelectMgr::getInstance()->deselectAll();
+ LLSelectMgr::getInstance()->selectObjectAndFamily(root);
+ handle_object_edit();
+}
+
+// ============================================================================
+// Animations tab -- Play/Stop on the user's avatar or the selected animesh.
+// ============================================================================
+class LLPanelLocalAnim final : public LLPanelLocalAssetBase
+{
+public:
+ void draw() override;
+
+protected:
+ void feedList() override
+ {
+ LLLocalAnimMgr::getInstance()->feedScrollList(mList);
+ }
+ void delUnit(const LLUUID& tracking_id) override
+ {
+ LLLocalAnimMgr::getInstance()->delUnit(tracking_id);
+ }
+ void loadPath(const std::string& path) override
+ {
+ LLLocalAnimMgr::getInstance()->addUnit(path);
+ }
+ LLUUID unitForPath(const std::string& path) const override
+ {
+ return LLLocalAnimMgr::getInstance()->getUnitID(path);
+ }
+ std::string pathForUnit(const LLUUID& tracking_id) const override
+ {
+ return LLLocalAnimMgr::getInstance()->getFilename(tracking_id);
+ }
+ std::string iconName() const override
+ {
+ return LLInventoryIcon::getIconName(LLAssetType::AT_ANIMATION, LLInventoryType::IT_ANIMATION);
+ }
+ LLLocalAssetPaths::EType assetType() const override { return LLLocalAssetPaths::TYPE_ANIM; }
+ LLFilePicker::ELoadFilter getLoadFilter() const override { return LLFilePicker::FFLOAD_ANIM; }
+ std::string emptyHint() const override { return getString("empty_hint_anim"); }
+ boost::signals2::connection connectChanged(const std::function& cb) override
+ {
+ return LLLocalAnimMgr::getInstance()->setUnitsChangedCallback(cb);
+ }
+ void doUpload(const std::string& path) override
+ {
+ // .anim / .bvh -> the standard animation upload floater.
+ upload_single_file(std::vector(1, path), LLFilePicker::FFLOAD_ANIM, LLUUID::null);
+ }
+
+ void initExtraButtons() override;
+ void updateExtraButtons(bool has_selection) override;
+
+private:
+ void onPlay();
+ void onStop();
+ void refreshPlayStop();
+ // The avatar Play/Stop act on: the user's own avatar, or the selected in-world
+ // animesh's control avatar, per the target combo.
+ LLVOAvatar* getTargetAvatar() const;
+
+ enum ETarget { TARGET_SELF = 0, TARGET_SELECTED = 1 };
+
+ LLComboBox* mTargetCombo { nullptr };
+ LLButton* mPlayBtn { nullptr };
+ LLButton* mStopBtn { nullptr };
+};
+
+void LLPanelLocalAnim::initExtraButtons()
+{
+ mTargetCombo = getChild("anim_target_combo");
+ mPlayBtn = getChild("play_btn");
+ mStopBtn = getChild("stop_btn");
+
+ mTargetCombo->setVisible(true);
+ mTargetCombo->setEnabled(true); // a mode selector -- always usable
+ mPlayBtn->setVisible(true);
+ mStopBtn->setVisible(true);
+
+ mTargetCombo->add(getString("target_self"), LLSD((S32)TARGET_SELF));
+ mTargetCombo->add(getString("target_selected"), LLSD((S32)TARGET_SELECTED));
+ mTargetCombo->selectByValue(LLSD((S32)TARGET_SELF));
+ mTargetCombo->setCommitCallback(boost::bind(&LLPanelLocalAnim::refreshPlayStop, this));
+
+ mPlayBtn->setCommitCallback(boost::bind(&LLPanelLocalAnim::onPlay, this));
+ mStopBtn->setCommitCallback(boost::bind(&LLPanelLocalAnim::onStop, this));
+}
+
+LLVOAvatar* LLPanelLocalAnim::getTargetAvatar() const
+{
+ const S32 target = mTargetCombo ? mTargetCombo->getValue().asInteger() : (S32)TARGET_SELF;
+ if (target == TARGET_SELECTED)
+ {
+ return get_selected_animesh_control_avatar();
+ }
+ return isAgentAvatarValid() ? gAgentAvatarp.get() : nullptr;
+}
+
+void LLPanelLocalAnim::refreshPlayStop()
+{
+ // The target avatar (self, or the selected in-world animesh) changes
+ // independently of this list, so keep Play/Stop enabled state live.
+ LLVOAvatar* target = getTargetAvatar();
+ const bool has_anim = mList && !mList->getAllSelected().empty();
+ if (mPlayBtn) { mPlayBtn->setEnabled(has_anim && target != nullptr); }
+ if (mStopBtn) { mStopBtn->setEnabled(target != nullptr); }
+}
+
+void LLPanelLocalAnim::updateExtraButtons(bool /*has_selection*/)
+{
+ refreshPlayStop();
+}
+
+void LLPanelLocalAnim::draw()
+{
+ refreshPlayStop();
+ LLPanel::draw();
+}
+
+void LLPanelLocalAnim::onPlay()
+{
+ LLVOAvatar* target = getTargetAvatar();
+ if (!target)
+ {
+ return;
+ }
+ LLUUID id = getSelectedID();
+ if (id.isNull())
+ {
+ // Undecoded selection: anim decode is synchronous, so load then play now.
+ const std::string path = getSelectedPath();
+ if (path.empty())
+ {
+ return;
+ }
+ loadPath(path);
+ id = unitForPath(path);
+ }
+ if (id.notNull())
+ {
+ LLLocalAnimMgr::getInstance()->playOnAvatar(target, id);
+ }
+}
+
+void LLPanelLocalAnim::onStop()
+{
+ if (LLVOAvatar* target = getTargetAvatar())
+ {
+ LLLocalAnimMgr::getInstance()->stopOnAvatar(target);
+ }
+}
+
+// Collect the faces to apply a local asset to on one selected object. Honors an
+// explicit Select-Face pick (a strict, non-empty subset of the object's faces); any
+// other state -- a whole-object selection, or nothing picked -- means every face.
+//
+// Iterates getNumTEs() (the texture-entry count), NOT getNumFaces() (the drawable's
+// *built* face count). For a freshly spawned or hot-swapped local mesh the volume
+// realizes a frame or more after the TEs are set, so getNumFaces() can still read a
+// placeholder count -- and applyToTEs()/selectionSetImage(), which clamp to
+// llmin(getNumTEs, getNumFaces), then cover only the first face(s). That clamp is
+// exactly why Apply had to be clicked twice: the first click hit one face, the apply
+// forced the volume to realize, and only then did a second click reach the rest.
+static void collect_apply_tes(const LLSelectNode* node, const LLViewerObject* obj, std::vector& out)
+{
+ out.clear();
+ const S32 num = (S32)obj->getNumTEs();
+ if (num <= 0)
+ {
+ return;
+ }
+ S32 picked = 0;
+ for (S32 te = 0; te < num; ++te)
+ {
+ if (node->isTESelected(te))
+ {
+ ++picked;
+ }
+ }
+ const bool subset = (picked > 0 && picked < num); // genuine per-face pick
+ for (S32 te = 0; te < num; ++te)
+ {
+ if (!subset || node->isTESelected(te))
+ {
+ out.push_back(te);
+ }
+ }
+}
+
+// Apply a local texture (world id) to the current in-world selection -- every face
+// unless specific faces are picked. Mirrors LLToolDragAndDrop::dropTextureAllFaces
+// (a plain setTEImage over getNumTEs()), so it isn't subject to the getNumFaces
+// clamp described on collect_apply_tes(). sendTEUpdate() is isLocalOnly-guarded, so
+// this is safe on both real objects and client-only previews.
+void apply_local_texture_to_selection(const LLUUID& world_id)
+{
+ LLViewerTexture* image = LLViewerTextureManager::getFetchedTexture(
+ world_id, FTT_DEFAULT, true, LLGLTexture::BOOST_NONE, LLViewerTexture::LOD_TEXTURE);
+ LLObjectSelectionHandle sel = LLSelectMgr::getInstance()->getSelection();
+ std::vector tes;
+ for (LLObjectSelection::iterator it = sel->begin(); it != sel->end(); ++it)
+ {
+ LLSelectNode* node = *it;
+ LLViewerObject* obj = node ? node->getObject() : nullptr;
+ if (!obj || !obj->permModify())
+ {
+ continue;
+ }
+ collect_apply_tes(node, obj, tes);
+ for (S32 te : tes)
+ {
+ obj->setTEImage(te, image);
+ }
+ obj->sendTEUpdate(); // isLocalOnly-guarded; a no-op for client-only previews
+ }
+}
+
+// Apply a local GLTF material (world id) to the current in-world selection -- every
+// face unless specific faces are picked. Real objects update the server (queued,
+// flushed once at the end); client-only (isLocalOnly) previews update local render
+// state only and then mark the render-material param in use so it survives the
+// drawable rebuild (no server echo does that for them -- same fix as
+// applyPartGeometry in lllocalmesh.cpp). Iterates getNumTEs() to dodge the
+// getNumFaces clamp (see collect_apply_tes).
+void apply_local_material_to_selection(const LLUUID& world_id)
+{
+ LLObjectSelectionHandle sel = LLSelectMgr::getInstance()->getSelection();
+ std::vector tes;
+ bool any_server = false;
+ for (LLObjectSelection::iterator it = sel->begin(); it != sel->end(); ++it)
+ {
+ LLSelectNode* node = *it;
+ LLViewerObject* obj = node ? node->getObject() : nullptr;
+ if (!obj || !obj->permModify())
+ {
+ continue;
+ }
+ const bool local = obj->isLocalOnly();
+ collect_apply_tes(node, obj, tes);
+ // For a client-only preview, mark the render-material param IN USE *before*
+ // setting per-face ids. setRenderMaterialID() creates a throwaway param block
+ // with in_use=false whenever the block isn't already in use (llviewerobject
+ // createNewParameterEntry), so without this each face's call would replace the
+ // block and only the LAST face would keep its material -- every other face
+ // renders untextured. Real objects get a per-face block from the server echo
+ // instead. (Same ordering as applyPartGeometry in lllocalmesh.cpp.)
+ if (local)
+ {
+ obj->setHasRenderMaterialParams(true);
+ }
+ else
+ {
+ any_server = true;
+ }
+ for (S32 te : tes)
+ {
+ obj->setRenderMaterialID(te, world_id, /*update_server=*/!local, /*local_origin=*/true);
+ }
+ }
+ if (any_server)
+ {
+ LLGLTFMaterialList::flushUpdates();
+ }
+}
+
+// ============================================================================
+// Apply-to-face base -- shared by the Textures and Materials tabs. Reuses the
+// hidden "spawn_btn" side slot as an "Apply to Face" button that applies the
+// selected local asset to the current in-world face selection.
+// ============================================================================
+class LLPanelLocalApplyAsset : public LLPanelLocalAssetBase
+{
+protected:
+ virtual std::string applyLabel() = 0; // button label
+ virtual LLUUID worldIdFor(const LLUUID& tracking_id) = 0; // unit -> world id
+ virtual void applyWorldId(const LLUUID& world_id) = 0; // apply to selection
+
+ void initExtraButtons() override
+ {
+ mApplyBtn = getChild("spawn_btn"); // per-tab instance; reuse the slot
+ mApplyBtn->setLabel(applyLabel());
+ mApplyBtn->setToolTip(getString("apply_tooltip")); // spawn_btn slot is repurposed per tab
+ mApplyBtn->setVisible(true);
+ mApplyBtn->setCommitCallback(boost::bind(&LLPanelLocalApplyAsset::onApply, this));
+ }
+ void updateExtraButtons(bool has_selection) override
+ {
+ if (mApplyBtn)
+ {
+ // Need both a chosen asset row and an in-world selection to apply to.
+ const bool has_target = LLSelectMgr::getInstance()->getSelection()->getNumNodes() > 0;
+ mApplyBtn->setEnabled(has_selection && has_target);
+ }
+ }
+ void draw() override
+ {
+ // In-world selection changes independently of the list, so keep this live.
+ updateExtraButtons(mList && mList->getFirstSelected() != nullptr);
+ LLPanel::draw();
+ }
+
+private:
+ void onApply()
+ {
+ LLUUID id = getSelectedID();
+ if (id.isNull())
+ {
+ // Undecoded row: decode it (bitmap/material loads are synchronous), then apply.
+ const std::string path = getSelectedPath();
+ if (path.empty())
+ {
+ return;
+ }
+ loadPath(path);
+ id = unitForPath(path);
+ }
+ if (id.isNull())
+ {
+ return;
+ }
+ const LLUUID world_id = worldIdFor(id);
+ if (world_id.notNull())
+ {
+ // Whole object -> all faces; specific Select-Face pick -> just those.
+ applyWorldId(world_id);
+ }
+ }
+
+ LLButton* mApplyBtn { nullptr };
+};
+
+// ============================================================================
+// Textures tab -- list + "Apply to Face" (applies a local texture to selection).
+// ============================================================================
+class LLPanelLocalTexture final : public LLPanelLocalApplyAsset
+{
+protected:
+ void feedList() override
+ {
+ LLLocalBitmapMgr::getInstance()->feedScrollList(mList);
+ }
+ void delUnit(const LLUUID& tracking_id) override
+ {
+ LLLocalBitmapMgr::getInstance()->delUnit(tracking_id);
+ }
+ void loadPath(const std::string& path) override
+ {
+ LLLocalBitmapMgr::getInstance()->addUnit(path);
+ }
+ LLUUID unitForPath(const std::string& path) const override
+ {
+ return LLLocalBitmapMgr::getInstance()->getUnitID(path);
+ }
+ std::string pathForUnit(const LLUUID& tracking_id) const override
+ {
+ return LLLocalBitmapMgr::getInstance()->getFilename(tracking_id);
+ }
+ std::string iconName() const override
+ {
+ return LLInventoryIcon::getIconName(LLAssetType::AT_TEXTURE, LLInventoryType::IT_TEXTURE);
+ }
+ LLLocalAssetPaths::EType assetType() const override { return LLLocalAssetPaths::TYPE_TEXTURE; }
+ LLFilePicker::ELoadFilter getLoadFilter() const override { return LLFilePicker::FFLOAD_IMAGE; }
+ std::string emptyHint() const override { return getString("empty_hint_tex"); }
+ boost::signals2::connection connectChanged(const std::function& cb) override
+ {
+ return LLLocalBitmapMgr::getInstance()->setUnitsChangedCallback(cb);
+ }
+ void doUpload(const std::string& path) override
+ {
+ upload_single_file(std::vector(1, path), LLFilePicker::FFLOAD_IMAGE, LLUUID::null);
+ }
+
+ std::string applyLabel() override { return getString("apply_texture_label"); }
+ LLUUID worldIdFor(const LLUUID& tracking_id) override
+ {
+ return LLLocalBitmapMgr::getInstance()->getWorldID(tracking_id);
+ }
+ void applyWorldId(const LLUUID& world_id) override
+ {
+ apply_local_texture_to_selection(world_id);
+ }
+};
+
+// ============================================================================
+// GLTF Materials tab -- one row per material (a .gltf can hold several).
+// List + "Apply to Face" (applies a local material to the in-world selection).
+// ============================================================================
+class LLPanelLocalMaterial final : public LLPanelLocalApplyAsset
+{
+protected:
+ void feedList() override
+ {
+ LLLocalGLTFMaterialMgr::getInstance()->feedScrollList(mList);
+ }
+ void delUnit(const LLUUID& tracking_id) override
+ {
+ LLLocalGLTFMaterialMgr::getInstance()->delUnit(tracking_id);
+ }
+ void loadPath(const std::string& path) override
+ {
+ LLLocalGLTFMaterialMgr::getInstance()->addUnit(path);
+ }
+ LLUUID unitForPath(const std::string& path) const override
+ {
+ // A file holds >= 1 material; treat it as loaded if its first material is.
+ return LLLocalGLTFMaterialMgr::getInstance()->getUnitID(path, 0);
+ }
+ std::string pathForUnit(const LLUUID& tracking_id) const override
+ {
+ std::string filename;
+ S32 index = 0;
+ LLLocalGLTFMaterialMgr::getInstance()->getFilenameAndIndex(tracking_id, filename, index);
+ return filename;
+ }
+ void unitsForPath(const std::string& path, std::vector& out) const override
+ {
+ // One .gltf/.glb can decode to several material units; remove them all.
+ LLLocalGLTFMaterialMgr::getInstance()->getTrackingIDs(path, out);
+ }
+ std::string iconName() const override
+ {
+ return LLInventoryIcon::getIconName(LLAssetType::AT_MATERIAL, LLInventoryType::IT_MATERIAL);
+ }
+ LLLocalAssetPaths::EType assetType() const override { return LLLocalAssetPaths::TYPE_MATERIAL; }
+ LLFilePicker::ELoadFilter getLoadFilter() const override { return LLFilePicker::FFLOAD_MATERIAL; }
+ std::string emptyHint() const override { return getString("empty_hint_mat"); }
+ boost::signals2::connection connectChanged(const std::function& cb) override
+ {
+ return LLLocalGLTFMaterialMgr::getInstance()->setUnitsChangedCallback(cb);
+ }
+ void doUpload(const std::string& path) override
+ {
+ // FFLOAD_GLTF routes to LLMaterialEditor::loadMaterialFromFile (the upload path).
+ upload_single_file(std::vector(1, path), LLFilePicker::FFLOAD_GLTF, LLUUID::null);
+ }
+
+ std::string applyLabel() override { return getString("apply_material_label"); }
+ LLUUID worldIdFor(const LLUUID& tracking_id) override
+ {
+ return LLLocalGLTFMaterialMgr::getInstance()->getWorldID(tracking_id);
+ }
+ void applyWorldId(const LLUUID& world_id) override
+ {
+ apply_local_material_to_selection(world_id);
+ }
+};
+
+// ============================================================================
+// Spawned Objects tab -- one row per rezzed copy across all meshes, with per-copy
+// Select / Derez and a Derez All. Not a file list (not an LLPanelLocalAssetBase):
+// its rows are in-world copies, fed from LLLocalMeshMgr::getSpawnedInstances(), and
+// the row value is the copy's instance id.
+// ============================================================================
+class LLPanelLocalSpawned final : public LLPanel
+{
+public:
+ bool postBuild() override;
+ void draw() override;
+
+private:
+ void refresh() override;
+ void onSelectionChange();
+ void onSelect();
+ void onFocus();
+ void onDerez();
+ void onDerezAll();
+ LLUUID firstSelectedInstance() const;
+
+ LLScrollListCtrl* mList { nullptr };
+ LLButton* mSelectBtn { nullptr };
+ LLButton* mFocusBtn { nullptr };
+ LLButton* mDerezBtn { nullptr };
+ LLButton* mDerezAllBtn { nullptr };
+ boost::signals2::scoped_connection mChangedConn;
+};
+
+bool LLPanelLocalSpawned::postBuild()
+{
+ mList = getChild("spawned_list");
+ mSelectBtn = getChild("select_btn");
+ mFocusBtn = getChild("focus_btn");
+ mDerezBtn = getChild("derez_btn");
+ mDerezAllBtn = getChild("derez_all_btn");
+
+ mList->setCommitOnSelectionChange(true);
+ mList->setCommitCallback(boost::bind(&LLPanelLocalSpawned::onSelectionChange, this));
+ mList->setDoubleClickCallback(boost::bind(&LLPanelLocalSpawned::onSelect, this));
+ mSelectBtn->setCommitCallback(boost::bind(&LLPanelLocalSpawned::onSelect, this));
+ mFocusBtn->setCommitCallback(boost::bind(&LLPanelLocalSpawned::onFocus, this));
+ mDerezBtn->setCommitCallback(boost::bind(&LLPanelLocalSpawned::onDerez, this));
+ mDerezAllBtn->setCommitCallback(boost::bind(&LLPanelLocalSpawned::onDerezAll, this));
+
+ // Reactive: the mesh manager signals on any spawn / despawn / attach change.
+ mChangedConn = LLLocalMeshMgr::getInstance()->setUnitsChangedCallback(
+ boost::bind(&LLPanelLocalSpawned::refresh, this));
+
+ refresh();
+ return true;
+}
+
+void LLPanelLocalSpawned::refresh()
+{
+ if (!mList)
+ {
+ return;
+ }
+ const LLUUID prev = firstSelectedInstance();
+ mList->clearRows();
+
+ LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance();
+ const std::string icon = LLInventoryIcon::getIconName(LLAssetType::AT_OBJECT, LLInventoryType::IT_OBJECT);
+ for (const LLLocalMeshMgr::SpawnedInstance& inst : mgr->getSpawnedInstances())
+ {
+ LLLocalMesh* unit = mgr->getUnit(inst.mTrackingID);
+
+ LLSD element;
+ element["columns"][0]["column"] = "icon";
+ element["columns"][0]["type"] = "icon";
+ element["columns"][0]["value"] = icon;
+ element["columns"][1]["column"] = "unit_name";
+ element["columns"][1]["type"] = "text";
+ element["columns"][1]["value"] = unit ? unit->getShortName() : LLStringUtil::null;
+ element["columns"][2]["column"] = "status";
+ element["columns"][2]["type"] = "text";
+ element["columns"][2]["value"] = mgr->statusText(inst.mRoot);
+
+ element["value"] = inst.mInstanceID; // identify the row by its copy
+ mList->addElement(element);
+ }
+
+ if (prev.notNull())
+ {
+ mList->selectByValue(LLSD(prev));
+ }
+ onSelectionChange();
+ mList->setCommentText(mList->getItemCount() == 0 ? getString("empty_hint") : LLStringUtil::null);
+}
+
+void LLPanelLocalSpawned::onSelectionChange()
+{
+ const bool has_sel = mList && !mList->getAllSelected().empty();
+ const bool any = mList && mList->getItemCount() > 0;
+ if (mSelectBtn) { mSelectBtn->setEnabled(has_sel); }
+ if (mFocusBtn) { mFocusBtn->setEnabled(has_sel); }
+ if (mDerezBtn) { mDerezBtn->setEnabled(has_sel); }
+ if (mDerezAllBtn) { mDerezAllBtn->setEnabled(any); }
+}
+
+void LLPanelLocalSpawned::draw()
+{
+ // In-world derez (the Delete key) changes the set independently of this list,
+ // so keep the buttons' enabled state live.
+ onSelectionChange();
+ LLPanel::draw();
+}
+
+LLUUID LLPanelLocalSpawned::firstSelectedInstance() const
+{
+ if (mList)
+ {
+ if (LLScrollListItem* item = mList->getFirstSelected())
+ {
+ return item->getValue().asUUID();
+ }
+ }
+ return LLUUID::null;
+}
+
+void LLPanelLocalSpawned::onSelect()
+{
+ LLViewerObject* root = LLLocalMeshMgr::getInstance()->getInstanceRoot(firstSelectedInstance());
+ if (!root)
+ {
+ return;
+ }
+ // Select + open Build to edit. Deliberately does NOT move the camera; framing the
+ // copy is the separate Focus Camera action below (artists dislike Select yanking
+ // the view, especially when picking through copies).
+ LLSelectMgr::getInstance()->deselectAll();
+ LLSelectMgr::getInstance()->selectObjectAndFamily(root);
+ handle_object_edit();
+}
+
+void LLPanelLocalSpawned::onFocus()
+{
+ LLViewerObject* root = LLLocalMeshMgr::getInstance()->getInstanceRoot(firstSelectedInstance());
+ if (!root)
+ {
+ return;
+ }
+ // Frame the copy: select it and point the camera at it (the deliberate move).
+ LLSelectMgr::getInstance()->deselectAll();
+ LLSelectMgr::getInstance()->selectObjectAndFamily(root);
+ gAgentCamera.setFocusOnAvatar(false, false);
+ gAgentCamera.setFocusGlobal(root->getPositionGlobal(), root->getID());
+}
+
+void LLPanelLocalSpawned::onDerez()
+{
+ if (!mList)
+ {
+ return;
+ }
+ // Snapshot ids first: despawnInstance() fires the manager signal -> refresh()
+ // rebuilds the list and frees the LLScrollListItems we'd be iterating.
+ std::vector ids;
+ for (LLScrollListItem* item : mList->getAllSelected())
+ {
+ if (item)
+ {
+ const LLUUID id = item->getValue().asUUID();
+ if (id.notNull())
+ {
+ ids.push_back(id);
+ }
+ }
+ }
+ for (const LLUUID& id : ids)
+ {
+ LLLocalMeshMgr::getInstance()->despawnInstance(id);
+ }
+}
+
+void LLPanelLocalSpawned::onDerezAll()
+{
+ LLLocalMeshMgr::getInstance()->despawnAll();
+}
+
+// Build a panel from XUI and add it as a tab.
+LLPanelLocalAssetBase* add_asset_tab(LLTabContainer* tabs, LLPanelLocalAssetBase* panel,
+ const std::string& name, const std::string& label,
+ const std::string& xml, bool select)
+{
+ panel->buildFromFile(xml);
+ panel->setName(name); // AFTER build: the shared XML's name would otherwise clobber it,
+ // leaving all tabs identically named (breaks getPanelByName routing)
+ tabs->addTabPanel(LLTabContainer::TabPanelParams().panel(panel).label(label).select_tab(select));
+ return panel;
+}
+
+} // anonymous namespace
+
+// ============================================================================
+// LLFloaterLocalAssets
+// ============================================================================
+LLFloaterLocalAssets::LLFloaterLocalAssets(const LLSD& key)
+: LLFloater(key)
+{
+}
+
+LLFloaterLocalAssets::~LLFloaterLocalAssets()
+{
+}
+
+bool LLFloaterLocalAssets::postBuild()
+{
+ mTabs = getChild("asset_tabs");
+
+ // Rezzed tab first (the in-world scene overview): one row per rezzed copy. Its
+ // own panel/XML, not a file list. setName() after buildFromFile so the XML's name
+ // doesn't clobber it. Mesh stays the default-selected tab (load assets first).
+ {
+ LLPanelLocalSpawned* spawned = new LLPanelLocalSpawned();
+ spawned->buildFromFile("panel_local_spawned.xml");
+ spawned->setName("spawned_tab");
+ mTabs->addTabPanel(LLTabContainer::TabPanelParams().panel(spawned)
+ .label(getString("tab_rezzed")).select_tab(false));
+ }
+
+ add_asset_tab(mTabs, new LLPanelLocalMesh(), "mesh_tab", getString("tab_mesh"),
+ "panel_local_asset_list.xml", true);
+ add_asset_tab(mTabs, new LLPanelLocalAnim(), "anim_tab", getString("tab_anim"),
+ "panel_local_asset_list.xml", false);
+ add_asset_tab(mTabs, new LLPanelLocalTexture(), "tex_tab", getString("tab_textures"),
+ "panel_local_asset_list.xml", false);
+ add_asset_tab(mTabs, new LLPanelLocalMaterial(), "mat_tab", getString("tab_materials"),
+ "panel_local_asset_list.xml", false);
+
+ return true;
+}
+
+void LLFloaterLocalAssets::dropFiles(const std::vector& paths)
+{
+ if (!mTabs)
+ {
+ return;
+ }
+ for (const std::string& path : paths)
+ {
+ std::string ext = gDirUtilp->getExtension(path);
+ LLStringUtil::toLower(ext);
+
+ std::string tab_name;
+ if (ext == "dae")
+ {
+ tab_name = "mesh_tab";
+ }
+ else if (ext == "gltf" || ext == "glb")
+ {
+ // A glTF can be a mesh or a material: honor the active tab if it's one of
+ // those, else default to Mesh.
+ LLPanel* cur = mTabs->getCurrentPanel();
+ tab_name = (cur && cur->getName() == "mat_tab") ? "mat_tab" : "mesh_tab";
+ }
+ else if (ext == "bvh" || ext == "anim")
+ {
+ tab_name = "anim_tab";
+ }
+ else if (ext == "bmp" || ext == "jpg" || ext == "jpeg" || ext == "png" ||
+ ext == "tga" || ext == "webp" || ext == "avif" || ext == "j2c" || ext == "jp2")
+ {
+ tab_name = "tex_tab";
+ }
+ else
+ {
+ continue; // not something the Local Assets tabs handle
+ }
+
+ // The tab panels are LLPanelLocalAssetBase (anon-namespace, visible here).
+ if (LLPanelLocalAssetBase* panel = dynamic_cast(mTabs->getPanelByName(tab_name)))
+ {
+ mTabs->selectTabPanel(panel);
+ panel->loadFile(path); // decode + add (+ persist) via the manager
+ }
+ }
+}
diff --git a/indra/newview/llfloaterlocalassets.h b/indra/newview/llfloaterlocalassets.h
new file mode 100644
index 0000000000..a56ee78503
--- /dev/null
+++ b/indra/newview/llfloaterlocalassets.h
@@ -0,0 +1,51 @@
+/**
+ * @file llfloaterlocalassets.h
+ * @brief Unified "Local Assets" floater (mesh / animation / texture / material previews)
+ *
+ * $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$
+ */
+
+#ifndef LL_LLFLOATERLOCALASSETS_H
+#define LL_LLFLOATERLOCALASSETS_H
+
+#include "llfloater.h"
+
+#include
+#include
+
+class LLTabContainer;
+
+class LLFloaterLocalAssets final : public LLFloater
+{
+public:
+ LLFloaterLocalAssets(const LLSD& key);
+ ~LLFloaterLocalAssets() override;
+
+ bool postBuild() override;
+
+ // Load OS-dropped files into the matching tabs, by extension (routed here from
+ // LLViewerWindow::handleDragNDropFile when the drop lands on this floater).
+ void dropFiles(const std::vector& paths);
+
+private:
+ LLTabContainer* mTabs { nullptr };
+};
+
+#endif // LL_LLFLOATERLOCALASSETS_H
diff --git a/indra/newview/llfloaterobjectweights.cpp b/indra/newview/llfloaterobjectweights.cpp
index 4b87d91bfd..11f58cac51 100644
--- a/indra/newview/llfloaterobjectweights.cpp
+++ b/indra/newview/llfloaterobjectweights.cpp
@@ -33,6 +33,7 @@
#include "lltextbox.h"
#include "llagent.h"
+#include "llappviewer.h"
#include "llviewerparcelmgr.h"
#include "llviewerregion.h"
@@ -125,14 +126,25 @@ void LLFloaterObjectWeights::onOpen(const LLSD& key)
// virtual
void LLFloaterObjectWeights::onWeightsUpdate(const SelectionCost& selection_cost)
{
- mSelectedDownloadWeight->setText(llformat("%.1f", selection_cost.mNetworkCost));
- mSelectedPhysicsWeight->setText(llformat("%.1f", selection_cost.mPhysicsCost));
- mSelectedServerWeight->setText(llformat("%.1f", selection_cost.mSimulationCost));
+ // The response can land on a later tick, after the floater has been closed.
+ // Capture a handle and bail if the floater is gone before touching widgets.
+ LLHandle handle = getHandle();
+ LLAppViewer::instance()->postToMainCoro(
+ [this, handle, selection_cost]()
+ {
+ if (handle.isDead())
+ {
+ return;
+ }
+ mSelectedDownloadWeight->setText(llformat("%.1f", selection_cost.mNetworkCost));
+ mSelectedPhysicsWeight->setText(llformat("%.1f", selection_cost.mPhysicsCost));
+ mSelectedServerWeight->setText(llformat("%.1f", selection_cost.mSimulationCost));
- S32 render_cost = LLSelectMgr::getInstance()->getSelection()->getSelectedObjectRenderCost();
- mSelectedDisplayWeight->setText(llformat("%d", render_cost));
+ S32 render_cost = LLSelectMgr::getInstance()->getSelection()->getSelectedObjectRenderCost();
+ mSelectedDisplayWeight->setText(llformat("%d", render_cost));
- toggleWeightsLoadingIndicators(false);
+ toggleWeightsLoadingIndicators(false);
+ });
}
//virtual
@@ -273,20 +285,46 @@ void LLFloaterObjectWeights::refresh()
LLViewerRegion* region = gAgent.getRegion();
if (region && region->capabilitiesReceived())
{
+ S32 server_costable_roots = 0;
for (LLObjectSelection::valid_root_iterator iter = sel_mgr->getSelection()->valid_root_begin();
iter != sel_mgr->getSelection()->valid_root_end(); ++iter)
{
- LLAccountingCostManager::getInstance()->addObject((*iter)->getObject()->getID());
+ // Client-only previews have no sim counterpart, so the
+ // ResourceCostSelected cap can't cost their fake UUIDs -- a request
+ // would never resolve and the weight indicators would spin forever.
+ LLViewerObject* root = (*iter)->getObject();
+ if (root && !root->isLocalOnly())
+ {
+ LLAccountingCostManager::getInstance()->addObject(root->getID());
+ ++server_costable_roots;
+ }
}
std::string url = region->getCapability("ResourceCostSelected");
- if (!url.empty())
+ if (server_costable_roots > 0)
{
- // Update the transaction id before the new fetch request
- generateTransactionID();
-
- LLAccountingCostManager::getInstance()->fetchCosts(Roots, url, getObserverHandle());
- toggleWeightsLoadingIndicators(true);
+ if (!url.empty())
+ {
+ // Update the transaction id before the new fetch request
+ generateTransactionID();
+
+ LLAccountingCostManager::getInstance()->fetchCosts(Roots, url, getObserverHandle());
+ toggleWeightsLoadingIndicators(true);
+ }
+ }
+ else
+ {
+ // Entirely client-only (local preview) selection. The sim-side weights
+ // (download/physics/server) can't be fetched, so zero them instead of
+ // spinning the loading indicators forever -- but the display/render
+ // weight is computed client-side, so keep it accurate.
+ toggleWeightsLoadingIndicators(false);
+ const std::string zero = llformat("%.1f", 0.f);
+ mSelectedDownloadWeight->setText(zero);
+ mSelectedPhysicsWeight->setText(zero);
+ mSelectedServerWeight->setText(zero);
+ const S32 render_cost = sel_mgr->getSelection()->getSelectedObjectRenderCost();
+ mSelectedDisplayWeight->setText(llformat("%d", render_cost));
}
}
else
diff --git a/indra/newview/llgltfmateriallist.cpp b/indra/newview/llgltfmateriallist.cpp
index deedabc8a2..0d545c24f5 100644
--- a/indra/newview/llgltfmateriallist.cpp
+++ b/indra/newview/llgltfmateriallist.cpp
@@ -327,6 +327,13 @@ void LLGLTFMaterialList::applyQueuedOverrides(LLViewerObject* obj)
void LLGLTFMaterialList::queueModify(const LLViewerObject* obj, S32 side, const LLGLTFMaterial* mat)
{
+ if (obj && obj->isLocalOnly())
+ {
+ // Client-only local mesh preview: it has no sim counterpart, so never
+ // queue a ModifyMaterialParams round-trip. Local render state is applied
+ // directly by the caller (setRenderMaterialID / setGLTFMaterialOverride).
+ return;
+ }
if (obj && obj->getRenderMaterialID(side).notNull())
{
if (mat == nullptr)
@@ -342,6 +349,10 @@ void LLGLTFMaterialList::queueModify(const LLViewerObject* obj, S32 side, const
void LLGLTFMaterialList::queueApply(const LLViewerObject* obj, S32 side, const LLUUID& asset_id)
{
+ if (obj && obj->isLocalOnly())
+ {
+ return; // client-only preview: no sim material sync (see queueModify)
+ }
const LLGLTFMaterial* material_override = obj->getTE(side)->getGLTFMaterialOverride();
if (material_override)
{
@@ -357,6 +368,10 @@ void LLGLTFMaterialList::queueApply(const LLViewerObject* obj, S32 side, const L
void LLGLTFMaterialList::queueApply(const LLViewerObject* obj, S32 side, const LLUUID& asset_id, const std::string &override_json)
{
+ if (obj && obj->isLocalOnly())
+ {
+ return; // client-only preview: no sim material sync (see queueModify)
+ }
if (asset_id.isNull() || override_json.empty())
{
// If there is no asset, there can't be an override
@@ -370,6 +385,10 @@ void LLGLTFMaterialList::queueApply(const LLViewerObject* obj, S32 side, const L
void LLGLTFMaterialList::queueApply(const LLViewerObject* obj, S32 side, const LLUUID& asset_id, const LLGLTFMaterial* material_override)
{
+ if (obj && obj->isLocalOnly())
+ {
+ return; // client-only preview: no sim material sync (see queueModify)
+ }
if (asset_id.isNull() || material_override == nullptr)
{
// If there is no asset, there can't be an override
diff --git a/indra/newview/lllocalanim.cpp b/indra/newview/lllocalanim.cpp
new file mode 100644
index 0000000000..95f9074e36
--- /dev/null
+++ b/indra/newview/lllocalanim.cpp
@@ -0,0 +1,519 @@
+/**
+ * @file lllocalanim.cpp
+ * @brief Local animation preview implementation
+ *
+ * $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 "llviewerprecompiledheaders.h"
+
+#include "lllocalanim.h"
+
+#include "llbvhloader.h"
+#include "llcharacter.h" // LLCharacter::sInstances (resolve avatar by id)
+#include "lldatapacker.h"
+#include "lldir.h"
+#include "llfile.h"
+#include "llinventoryicon.h"
+#include "llkeyframemotion.h"
+#include "llscrolllistctrl.h"
+#include "llstring.h"
+#include "llvoavatar.h"
+#include "llvoavatarself.h" // gAgentAvatarp, isAgentAvatarValid (BVH joint aliases)
+
+namespace
+{
+ constexpr F32 LOCAL_ANIM_TIMER_HEARTBEAT = 3.0f; // seconds between file-change polls
+
+ // Resolve an avatar id to a live avatar, including animesh control avatars
+ // (both are LLCharacters, registered in LLCharacter::sInstances).
+ LLVOAvatar* resolve_avatar(const LLUUID& av_id)
+ {
+ for (LLCharacter* character : LLCharacter::sInstances)
+ {
+ if (character && character->getID() == av_id)
+ {
+ return dynamic_cast(character);
+ }
+ }
+ return nullptr;
+ }
+}
+
+LLLocalAnimMgr::LLLocalAnimMgr()
+{
+ mTimer.stopTimer(); // started on demand once the first unit is added
+}
+
+LLLocalAnimMgr::~LLLocalAnimMgr()
+{
+ mTimer.stopTimer();
+ // Drop the keyframe data we cached globally for our local motions.
+ for (const auto& entry : mAnims)
+ {
+ LLKeyframeDataCache::removeKeyframeData(entry.first);
+ }
+ mAnims.clear();
+}
+
+bool LLLocalAnimMgr::decodeFile(const std::string& filename, std::vector& out_keyframe, bool* out_alias_deferred) const
+{
+ std::error_code ec;
+ LLFile infile;
+ infile.open(filename, LLFile::in | LLFile::binary, ec);
+ if (!infile || ec)
+ {
+ LL_WARNS("LocalAnim") << "Can't open animation file: " << filename << LL_ENDL;
+ return false;
+ }
+
+ // Reject unsupported types and absurd sizes BEFORE buffering the whole file,
+ // so a wrong or huge pick can't allocate unbounded memory / stall the viewer.
+ std::string ext = gDirUtilp->getExtension(filename);
+ LLStringUtil::toLower(ext);
+ if (ext != "anim" && ext != "bvh")
+ {
+ LL_WARNS("LocalAnim") << "Unsupported animation file type '." << ext << "': " << filename << LL_ENDL;
+ return false;
+ }
+
+ constexpr S64 MAX_LOCAL_ANIM_BYTES = 64ll * 1024 * 1024;
+ const S64 file_size = infile.size(ec);
+ if (file_size <= 0 || file_size > MAX_LOCAL_ANIM_BYTES || ec)
+ {
+ LL_WARNS("LocalAnim") << "Empty, oversized, or unreadable animation file: " << filename << LL_ENDL;
+ return false;
+ }
+
+ std::vector data((size_t)file_size);
+ if ((S64)infile.read(data.data(), file_size, ec) != file_size || ec)
+ {
+ LL_WARNS("LocalAnim") << "Short read on animation file: " << filename << LL_ENDL;
+ return false;
+ }
+ infile.close();
+
+ // Decode to LLKeyframeMotion serialized form. A .anim file already IS that form;
+ // a .bvh is parsed and serialized the same way the upload path does.
+ if (ext == "anim")
+ {
+ // Validate that the bytes actually deserialize before accepting them, so a
+ // truncated/corrupt save isn't committed -- and, via doUpdates()'s "keep last
+ // good on failure", doesn't blank a live preview on hot reload. Use a throwaway
+ // motion id on the agent avatar so playback and the global keyframe cache are
+ // untouched. (No avatar yet -> can't validate here; the size/ext guards apply.)
+ if (isAgentAvatarValid())
+ {
+ LLUUID probe_id;
+ probe_id.generate();
+ if (LLKeyframeMotion* probe = dynamic_cast(gAgentAvatarp->createMotion(probe_id)))
+ {
+ LLDataPackerBinaryBuffer probe_dp(data.data(), (S32)data.size());
+ const bool ok = probe->deserialize(probe_dp, probe_id, false);
+ gAgentAvatarp->removeMotion(probe_id);
+ LLKeyframeDataCache::removeKeyframeData(probe_id);
+ if (!ok)
+ {
+ LL_WARNS("LocalAnim") << "Corrupt .anim (failed to deserialize): " << filename << LL_ENDL;
+ return false;
+ }
+ }
+ }
+ out_keyframe = std::move(data);
+ }
+ else if (ext == "bvh")
+ {
+ data.push_back(0); // LLBVHLoader wants a null-terminated text buffer
+ ELoadStatus load_status = E_ST_OK;
+ S32 line_number = 0;
+ std::map> joint_alias_map;
+ if (isAgentAvatarValid())
+ {
+ joint_alias_map = gAgentAvatarp->getJointAliases();
+ }
+ else if (out_alias_deferred)
+ {
+ // No agent avatar yet (e.g. login-time path restore): decode without joint
+ // aliases now, but signal that a re-decode is owed once the avatar exists,
+ // so alias-dependent joints aren't permanently mismapped.
+ *out_alias_deferred = true;
+ }
+ LLBVHLoader loader((const char*)data.data(), load_status, line_number, joint_alias_map);
+ if (!loader.isInitialized())
+ {
+ LL_WARNS("LocalAnim") << "BVH parse failed (status " << load_status << ", line "
+ << line_number << "): " << filename << LL_ENDL;
+ return false;
+ }
+ const U32 out_size = loader.getOutputSize();
+ out_keyframe.resize(out_size);
+ LLDataPackerBinaryBuffer dp(out_keyframe.data(), (S32)out_size);
+ loader.serialize(dp); // BVH -> keyframe (.anim) bytes
+ }
+ else
+ {
+ LL_WARNS("LocalAnim") << "Unsupported animation file type '." << ext << "': " << filename << LL_ENDL;
+ return false;
+ }
+
+ if (out_keyframe.empty())
+ {
+ LL_WARNS("LocalAnim") << "No animation data decoded from " << filename << LL_ENDL;
+ return false;
+ }
+ return true;
+}
+
+LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename)
+{
+ // No double-add: a file that's already loaded just returns its existing unit.
+ // (Live-reload re-decodes in doUpdates(), not here, so this doesn't block it.)
+ if (LLUUID existing = getUnitID(filename); existing.notNull())
+ {
+ return existing;
+ }
+
+ std::vector keyframe;
+ bool alias_deferred = false;
+ if (!decodeFile(filename, keyframe, &alias_deferred))
+ {
+ return LLUUID::null;
+ }
+
+ LLUUID id;
+ id.generate();
+
+ LocalAnim anim;
+ anim.mFilename = filename;
+ anim.mShortName = gDirUtilp->getBaseFileName(filename, true /* strip extension */);
+ anim.mData = std::move(keyframe);
+ // Leave mLastModified at its epoch default when the decode was deferred (a .bvh
+ // decoded before the avatar's joint aliases existed): doUpdates() then re-decodes
+ // it once the avatar is ready. Otherwise record the file's current mtime.
+ if (!alias_deferred)
+ {
+ std::error_code ec;
+ anim.mLastModified = std::filesystem::last_write_time(filename, ec);
+ }
+
+ const size_t bytes = anim.mData.size();
+ mAnims[id] = std::move(anim);
+
+ if (!mTimer.isRunning())
+ {
+ mTimer.startTimer(); // begin watching source files for live reload
+ }
+
+ mUnitsChangedSignal();
+ LL_INFOS("LocalAnim") << "Loaded local anim '" << mAnims[id].mShortName << "' ("
+ << bytes << " bytes) as " << id << LL_ENDL;
+ return id;
+}
+
+LLUUID LLLocalAnimMgr::addUnit(const std::string& filename)
+{
+ return loadAnim(filename);
+}
+
+bool LLLocalAnimMgr::addUnit(const std::vector& filenames)
+{
+ bool any = false;
+ for (const std::string& filename : filenames)
+ {
+ if (!filename.empty() && loadAnim(filename).notNull())
+ {
+ any = true;
+ }
+ }
+ return any;
+}
+
+void LLLocalAnimMgr::delUnit(LLUUID tracking_id)
+{
+ auto iter = mAnims.find(tracking_id);
+ if (iter == mAnims.end())
+ {
+ return;
+ }
+
+ // Stop it wherever it's playing, purge the cached motion, and drop the play map.
+ for (auto pit = mPlaying.begin(); pit != mPlaying.end(); )
+ {
+ if (pit->second == tracking_id)
+ {
+ if (LLVOAvatar* av = resolve_avatar(pit->first))
+ {
+ av->stopMotion(tracking_id, true);
+ av->removeMotion(tracking_id);
+ }
+ pit = mPlaying.erase(pit);
+ }
+ else
+ {
+ ++pit;
+ }
+ }
+
+ LLKeyframeDataCache::removeKeyframeData(tracking_id);
+ mAnims.erase(iter);
+
+ if (mAnims.empty())
+ {
+ mTimer.stopTimer(); // nothing left to watch
+ }
+ mUnitsChangedSignal();
+ LL_INFOS("LocalAnim") << "Removed local anim " << tracking_id << LL_ENDL;
+}
+
+boost::signals2::connection LLLocalAnimMgr::setUnitsChangedCallback(const std::function& cb)
+{
+ return mUnitsChangedSignal.connect(cb);
+}
+
+LLUUID LLLocalAnimMgr::getUnitID(const std::string& filename) const
+{
+ for (const auto& entry : mAnims)
+ {
+ if (entry.second.mFilename == filename)
+ {
+ return entry.first;
+ }
+ }
+ return LLUUID::null;
+}
+
+std::string LLLocalAnimMgr::getFilename(const LLUUID& tracking_id) const
+{
+ auto iter = mAnims.find(tracking_id);
+ return (iter != mAnims.end()) ? iter->second.mFilename : std::string();
+}
+
+std::vector LLLocalAnimMgr::getFilenames() const
+{
+ std::vector out;
+ out.reserve(mAnims.size());
+ for (const auto& entry : mAnims)
+ {
+ out.push_back(entry.second.mFilename);
+ }
+ return out;
+}
+
+void LLLocalAnimMgr::feedScrollList(LLScrollListCtrl* ctrl)
+{
+ if (!ctrl)
+ {
+ return;
+ }
+
+ const std::string icon_name = LLInventoryIcon::getIconName(LLAssetType::AT_ANIMATION, LLInventoryType::IT_ANIMATION);
+
+ for (const auto& entry : mAnims)
+ {
+ LLSD element;
+ element["columns"][0]["column"] = "icon";
+ element["columns"][0]["type"] = "icon";
+ element["columns"][0]["value"] = icon_name;
+
+ element["columns"][1]["column"] = "unit_name";
+ element["columns"][1]["type"] = "text";
+ element["columns"][1]["value"] = entry.second.mShortName;
+
+ LLSD data;
+ data["id"] = entry.first;
+ data["type"] = (S32)LLAssetType::AT_ANIMATION;
+ element["value"] = data;
+
+ ctrl->addElement(element);
+ }
+}
+
+bool LLLocalAnimMgr::reapplyToAvatar(const LLUUID& av_id, const LLUUID& anim_id)
+{
+ LLVOAvatar* av = resolve_avatar(av_id);
+ auto iter = mAnims.find(anim_id);
+ if (!av || iter == mAnims.end())
+ {
+ return false;
+ }
+
+ // Purge the stale parsed motion instance so createMotion() yields a fresh one
+ // that deserializes the new bytes. The id is keyed per-avatar, so the old motion
+ // must go before a new one can take its place.
+ av->stopMotion(anim_id, true);
+ av->removeMotion(anim_id);
+
+ LLKeyframeMotion* motionp = dynamic_cast(av->createMotion(anim_id));
+ if (!motionp)
+ {
+ return false;
+ }
+ LLDataPackerBinaryBuffer dp(iter->second.mData.data(), (S32)iter->second.mData.size());
+ if (motionp->deserialize(dp, anim_id, false))
+ {
+ av->startMotion(anim_id);
+ return true;
+ }
+ return false;
+}
+
+void LLLocalAnimMgr::doUpdates()
+{
+ // Stop/restart around the sweep so a long poll can't re-enter via the timer.
+ mTimer.stopTimer();
+
+ for (auto& entry : mAnims)
+ {
+ const LLUUID& id = entry.first;
+ LocalAnim& anim = entry.second;
+
+ std::error_code ec;
+ const auto mtime = std::filesystem::last_write_time(anim.mFilename, ec);
+ if (ec || mtime == anim.mLastModified)
+ {
+ continue;
+ }
+ std::vector fresh;
+ bool alias_deferred = false;
+ if (!decodeFile(anim.mFilename, fresh, &alias_deferred))
+ {
+ continue; // keep last good data; don't consume mtime so a mid-save retries
+ }
+ anim.mData = std::move(fresh);
+
+ // Refresh the cached keyframe data so the next play uses the new bytes,
+ // and re-apply live to any avatar currently playing this id.
+ LLKeyframeDataCache::removeKeyframeData(id);
+ bool reapply_ok = true;
+ for (const auto& play : mPlaying)
+ {
+ if (play.second == id)
+ {
+ reapply_ok = reapplyToAvatar(play.first, id) && reapply_ok;
+ }
+ }
+
+ // Consume the mtime only once the swap is fully live AND the decode used joint
+ // aliases. Otherwise leave it so the next heartbeat retries: a transient reapply
+ // failure (the live preview tears down before the replacement is built, so it
+ // would otherwise stay blank), or a .bvh decoded before the avatar's aliases
+ // existed (re-decodes correctly once the avatar is ready).
+ if (reapply_ok && !alias_deferred)
+ {
+ anim.mLastModified = mtime;
+ LL_INFOS("LocalAnim") << "Live-reloaded local anim '" << anim.mShortName << "'" << LL_ENDL;
+ }
+ }
+
+ if (!mAnims.empty())
+ {
+ mTimer.startTimer();
+ }
+}
+
+bool LLLocalAnimMgr::playOnAvatar(LLVOAvatar* av, const LLUUID& anim_id)
+{
+ auto iter = mAnims.find(anim_id);
+ if (!av || iter == mAnims.end())
+ {
+ return false;
+ }
+
+ // createMotion() returns a load-pending LLKeyframeMotion for an unknown id; we
+ // then hand it the keyframe data locally (deserialize() also caches it globally
+ // via LLKeyframeDataCache, so replays -- and a freshly recreated control avatar
+ // -- can resolve the id without an asset fetch that would never arrive).
+ LLKeyframeMotion* motionp = dynamic_cast(av->createMotion(anim_id));
+ if (!motionp)
+ {
+ LL_WARNS("LocalAnim") << "createMotion failed for " << anim_id << LL_ENDL;
+ return false;
+ }
+
+ if (!LLKeyframeDataCache::getKeyframeData(anim_id))
+ {
+ LLDataPackerBinaryBuffer dp(iter->second.mData.data(), (S32)iter->second.mData.size());
+ if (!motionp->deserialize(dp, anim_id, false))
+ {
+ LL_WARNS("LocalAnim") << "Failed to deserialize local anim '"
+ << iter->second.mShortName << "'" << LL_ENDL;
+ return false;
+ }
+ }
+
+ // Replace any local anim already playing on this control avatar.
+ const LLUUID av_id = av->getID();
+ auto prev = mPlaying.find(av_id);
+ if (prev != mPlaying.end() && prev->second != anim_id)
+ {
+ av->stopMotion(prev->second, false);
+ }
+
+ av->startMotion(anim_id);
+ mPlaying[av_id] = anim_id;
+ LL_INFOS("LocalAnim") << "Playing local anim '" << iter->second.mShortName << "'" << LL_ENDL;
+ return true;
+}
+
+void LLLocalAnimMgr::stopOnAvatar(LLVOAvatar* av)
+{
+ if (!av)
+ {
+ return;
+ }
+ auto iter = mPlaying.find(av->getID());
+ if (iter != mPlaying.end())
+ {
+ av->stopMotion(iter->second, false);
+ mPlaying.erase(iter);
+ }
+}
+
+bool LLLocalAnimMgr::isLocalAnim(const LLUUID& anim_id) const
+{
+ return mAnims.find(anim_id) != mAnims.end();
+}
+
+std::string LLLocalAnimMgr::getShortName(const LLUUID& anim_id) const
+{
+ auto iter = mAnims.find(anim_id);
+ return (iter != mAnims.end()) ? iter->second.mShortName : std::string();
+}
+
+/*=======================================*/
+/* LLLocalAnimTimer: live-reload poll */
+/*=======================================*/
+LLLocalAnimMgr::LLLocalAnimTimer::LLLocalAnimTimer()
+ : LLEventTimer(LOCAL_ANIM_TIMER_HEARTBEAT)
+{
+}
+
+void LLLocalAnimMgr::LLLocalAnimTimer::startTimer() { mEventTimer.start(); }
+void LLLocalAnimMgr::LLLocalAnimTimer::stopTimer() { mEventTimer.stop(); }
+bool LLLocalAnimMgr::LLLocalAnimTimer::isRunning() { return mEventTimer.getStarted(); }
+
+bool LLLocalAnimMgr::LLLocalAnimTimer::tick()
+{
+ if (LLLocalAnimMgr::instanceExists())
+ {
+ LLLocalAnimMgr::getInstance()->doUpdates();
+ }
+ return false; // keep ticking
+}
diff --git a/indra/newview/lllocalanim.h b/indra/newview/lllocalanim.h
new file mode 100644
index 0000000000..ee477eb371
--- /dev/null
+++ b/indra/newview/lllocalanim.h
@@ -0,0 +1,130 @@
+/**
+ * @file lllocalanim.h
+ * @brief Local animation preview header
+ *
+ * $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$
+ */
+
+// Local Animation is the animation analog of Local Mesh (lllocalmesh.h): it loads
+// a Second Life internal animation file (.anim) or a .bvh from disk, assigns it a
+// client-only motion UUID, and plays it on an avatar -- typically the control
+// avatar of a local mesh that has been made an animated object (animesh) -- without
+// uploading to the asset server. A .anim file IS the LLKeyframeMotion serialized
+// format; a .bvh is parsed and serialized the same way the upload path does. The
+// manager mirrors the shared local-asset registry API (LLLocalMeshMgr /
+// LLLocalBitmapMgr): addUnit/delUnit/getUnitID/getFilename/feedScrollList and a
+// doUpdates() live-reload tick driven by a periodic timer.
+
+#ifndef LL_LLLOCALANIM_H
+#define LL_LLLOCALANIM_H
+
+#include "lleventtimer.h"
+#include "llsingleton.h"
+#include "lluuid.h"
+
+#include
+#include
+#include