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 +#include +#include + +class LLScrollListCtrl; +class LLVOAvatar; + +// Owns loaded local animations and plays them on control avatars. +class LLLocalAnimMgr : public LLSingleton +{ + LLSINGLETON(LLLocalAnimMgr); + ~LLLocalAnimMgr(); + +public: + // --- Shared local-asset registry API (mirrors LLLocalMeshMgr) ------------- + LLUUID addUnit(const std::string& filename); + bool addUnit(const std::vector& filenames); + void delUnit(LLUUID tracking_id); + + LLUUID getUnitID(const std::string& filename) const; + std::string getFilename(const LLUUID& tracking_id) const; + // Every currently-loaded source file path (for cross-session persistence). + std::vector getFilenames() const; + + void feedScrollList(LLScrollListCtrl* ctrl); + + // Fired when the unit list changes (add/remove) so the Local Assets floater + // refreshes reactively. + boost::signals2::connection setUnitsChangedCallback(const std::function& cb); + + // Timer tick: poll every loaded anim's source file for changes and live-reload. + void doUpdates(); + + // --- Loading / playback --------------------------------------------------- + // Load a local .anim/.bvh file into a client-only motion. Returns the motion id + // (which doubles as the tracking id), or null on failure. Kept public for the + // in-world right-click "Play Local Animation" path; addUnit() wraps it. + LLUUID loadAnim(const std::string& filename); + + // Play a loaded local anim on the given avatar (the animesh control av), + // replacing whatever local anim was playing on it. stopOnAvatar() stops the + // local anim currently playing on that avatar. + bool playOnAvatar(LLVOAvatar* av, const LLUUID& anim_id); + void stopOnAvatar(LLVOAvatar* av); + + bool isLocalAnim(const LLUUID& anim_id) const; + std::string getShortName(const LLUUID& anim_id) const; + +private: + struct LocalAnim + { + std::string mFilename; + std::string mShortName; + std::vector mData; // raw .anim bytes == LLKeyframeMotion serialized form + std::filesystem::file_time_type mLastModified {}; // for live reload + }; + std::map mAnims; + + // Which local anim is currently playing on each control avatar (by avatar id), + // so a later play can replace it, "Stop"/delete can end it, and live-reload can + // re-apply fresh data to it. + std::map mPlaying; + + boost::signals2::signal mUnitsChangedSignal; // add/remove + + // Decode a .anim/.bvh file into keyframe bytes (the LLKeyframeMotion form). + // out_alias_deferred (optional): set true when a .bvh is decoded before the agent + // avatar exists, so its joint aliases were unavailable and a re-decode is owed. + bool decodeFile(const std::string& filename, std::vector& out_keyframe, bool* out_alias_deferred = nullptr) const; + // Re-deserialize fresh bytes onto an avatar already playing this id (live reload). + // Returns true only when the replacement motion is live on the avatar. + bool reapplyToAvatar(const LLUUID& av_id, const LLUUID& anim_id); + + // Live-reload polling (mirrors LLLocalMeshTimer). + class LLLocalAnimTimer : public LLEventTimer + { + public: + LLLocalAnimTimer(); + void startTimer(); + void stopTimer(); + bool isRunning(); + bool tick() override; + }; + LLLocalAnimTimer mTimer; +}; + +#endif // LL_LLLOCALANIM_H diff --git a/indra/newview/lllocalassetpaths.cpp b/indra/newview/lllocalassetpaths.cpp new file mode 100644 index 0000000000..53fb163c16 --- /dev/null +++ b/indra/newview/lllocalassetpaths.cpp @@ -0,0 +1,265 @@ +/** + * @file lllocalassetpaths.cpp + * @brief Per-account persistence of locally-loaded asset file paths + * + * $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 "lllocalassetpaths.h" + +#include "lldir.h" +#include "llfile.h" +#include "llsdserialize.h" + +#include "lllocalanim.h" +#include "lllocalbitmaps.h" +#include "lllocalgltfmaterials.h" +#include "lllocalmesh.h" + +#include +#include + +namespace +{ + const std::string LOCAL_ASSETS_FILE("local_assets.xml"); +} + +// static +const char* LLLocalAssetPaths::key(EType type) +{ + switch (type) + { + case TYPE_ANIM: return "anims"; + case TYPE_TEXTURE: return "textures"; + case TYPE_MATERIAL: return "materials"; + case TYPE_MESH: + default: return "meshes"; + } +} + +std::vector LLLocalAssetPaths::currentFiles(EType type) const +{ + switch (type) + { + case TYPE_ANIM: return LLLocalAnimMgr::getInstance()->getFilenames(); + case TYPE_TEXTURE: return LLLocalBitmapMgr::getInstance()->getFilenames(); + case TYPE_MATERIAL: return LLLocalGLTFMaterialMgr::getInstance()->getFilenames(); + case TYPE_MESH: + default: return LLLocalMeshMgr::getInstance()->getFilenames(); + } +} + +std::string LLLocalAssetPaths::getFilePath() const +{ + // Empty until the per-account dir is set (i.e. after login). + return gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, LOCAL_ASSETS_FILE); +} + +LLSD LLLocalAssetPaths::getPaths(EType type) const +{ + const char* k = key(type); + return mPaths.has(k) ? mPaths[k] : LLSD::emptyArray(); +} + +bool LLLocalAssetPaths::getMeshJoints(const std::string& path) const +{ + return mPaths.has("mesh_joints") && + mPaths["mesh_joints"].has(path) && + mPaths["mesh_joints"][path].asBoolean(); +} + +void LLLocalAssetPaths::writeToDisk() const +{ + const std::string path = getFilePath(); + if (path.empty()) + { + return; + } + llofstream out(path.c_str()); + if (out.is_open()) + { + LLSDSerialize::toXML(mPaths, out); + out.close(); + } + else + { + LL_WARNS("LocalAssets") << "Can't write local asset list: " << path << LL_ENDL; + } +} + +void LLLocalAssetPaths::removePath(EType type, const std::string& path) +{ + const char* k = key(type); + bool changed = false; + if (mPaths.has(k) && mPaths[k].isArray()) + { + LLSD kept = LLSD::emptyArray(); + for (LLSD::array_const_iterator it = mPaths[k].beginArray(); it != mPaths[k].endArray(); ++it) + { + if (it->asString() == path) { changed = true; } else { kept.append(*it); } + } + if (changed) { mPaths[k] = kept; } + } + if (type == TYPE_MESH && mPaths.has("mesh_joints") && mPaths["mesh_joints"].has(path)) + { + mPaths["mesh_joints"].erase(path); + changed = true; + } + if (changed) + { + writeToDisk(); + } +} + +void LLLocalAssetPaths::onUnitsChanged() +{ + // Remember any files that have become loaded since we last saved (lazy loads, + // fresh adds, the texture picker, ...). We only ADD here; user removals go + // through removePath(). + bool changed = false; + for (S32 t = TYPE_MESH; t <= TYPE_MATERIAL; ++t) + { + const char* k = key((EType)t); + if (!mPaths.has(k) || !mPaths[k].isArray()) + { + mPaths[k] = LLSD::emptyArray(); + } + std::set have; + for (LLSD::array_const_iterator it = mPaths[k].beginArray(); it != mPaths[k].endArray(); ++it) + { + have.insert(it->asString()); + } + for (const std::string& file : currentFiles((EType)t)) + { + if (have.insert(file).second) + { + mPaths[k].append(file); + changed = true; + } + } + } + + // Mesh-only: also remember each loaded mesh's joint-position-override flag (it can + // change without the file list changing). Untouched for unloaded meshes. + { + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + for (const std::string& file : mgr->getFilenames()) + { + const bool joints = mgr->getIncludeJointPositions(mgr->getUnitID(file)); + const bool had = mPaths["mesh_joints"].has(file) && mPaths["mesh_joints"][file].asBoolean(); + if (joints != had) + { + if (joints) { mPaths["mesh_joints"][file] = true; } + else { mPaths["mesh_joints"].erase(file); } + changed = true; + } + } + } + + if (changed) + { + writeToDisk(); + } +} + +void LLLocalAssetPaths::loadAndWatch() +{ + // Re-read the CURRENT account's saved paths on every call. Login cleanup is + // re-entered on relog / account switch, so this refreshes mPaths for the new + // account's local_assets.xml instead of leaving the previous account's set in + // memory (which onUnitsChanged() would then write into the new account's file). + reloadForAccount(); + + if (mWatching) + { + return; // register the manager watchers only once per session + } + mWatching = true; + + // Watch for files that become loaded from here on, so the saved set stays current. + mConnections.emplace_back(LLLocalMeshMgr::getInstance()->setUnitsChangedCallback( + boost::bind(&LLLocalAssetPaths::onUnitsChanged, this))); + mConnections.emplace_back(LLLocalAnimMgr::getInstance()->setUnitsChangedCallback( + boost::bind(&LLLocalAssetPaths::onUnitsChanged, this))); + mConnections.emplace_back(LLLocalBitmapMgr::getInstance()->setUnitsChangedCallback( + boost::bind(&LLLocalAssetPaths::onUnitsChanged, this))); + mConnections.emplace_back(LLLocalGLTFMaterialMgr::getInstance()->setUnitsChangedCallback( + boost::bind(&LLLocalAssetPaths::onUnitsChanged, this))); +} + +void LLLocalAssetPaths::reloadForAccount() +{ + // Discard any in-memory set from a previous account before reading. + mPaths = LLSD(); + + // Read the saved paths (no decoding). + const std::string path = getFilePath(); + if (!path.empty() && gDirUtilp->fileExists(path)) + { + llifstream in(path.c_str()); + if (in.is_open()) + { + LLSDSerialize::fromXML(mPaths, in); + in.close(); + } + } + + // Normalize to four arrays and drop entries whose file no longer exists. + bool pruned = false; + for (S32 t = TYPE_MESH; t <= TYPE_MATERIAL; ++t) + { + const char* k = key((EType)t); + LLSD kept = LLSD::emptyArray(); + if (mPaths.has(k) && mPaths[k].isArray()) + { + for (LLSD::array_const_iterator it = mPaths[k].beginArray(); it != mPaths[k].endArray(); ++it) + { + const std::string file = it->asString(); + if (!file.empty() && gDirUtilp->fileExists(file)) { kept.append(file); } + else { pruned = true; } + } + } + mPaths[k] = kept; + } + + // Drop joint flags for meshes no longer in the saved list. + if (mPaths.has("mesh_joints") && mPaths["mesh_joints"].isMap()) + { + std::set mesh_set; + for (LLSD::array_const_iterator it = mPaths["meshes"].beginArray(); it != mPaths["meshes"].endArray(); ++it) + { + mesh_set.insert(it->asString()); + } + LLSD kept_joints = LLSD::emptyMap(); + for (LLSD::map_const_iterator it = mPaths["mesh_joints"].beginMap(); it != mPaths["mesh_joints"].endMap(); ++it) + { + if (mesh_set.count(it->first)) { kept_joints[it->first] = it->second; } + else { pruned = true; } + } + mPaths["mesh_joints"] = kept_joints; + } + + if (pruned) + { + writeToDisk(); + } +} diff --git a/indra/newview/lllocalassetpaths.h b/indra/newview/lllocalassetpaths.h new file mode 100644 index 0000000000..bcd580bc2a --- /dev/null +++ b/indra/newview/lllocalassetpaths.h @@ -0,0 +1,79 @@ +/** + * @file lllocalassetpaths.h + * @brief Per-account persistence of locally-loaded asset file paths + * + * $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$ + */ + +// Remembers the set of locally-loaded asset file paths (mesh / animation / texture / +// GLTF material) per account, so an artist's working set reappears after a relog. +// Only the file PATHS are saved. +// +// Crucially, on login the files are NOT decoded -- the saved paths are just listed in +// the Local Assets floater (dimmed) and each is decoded lazily when the artist first +// uses it. This keeps login fast and memory low. The path set tracks newly-loaded +// files via the managers' units-changed signals, and the floater removes paths the +// user deletes. Stored at LL_PATH_PER_SL_ACCOUNT/local_assets.xml. + +#ifndef LL_LLLOCALASSETPATHS_H +#define LL_LLLOCALASSETPATHS_H + +#include "llsd.h" +#include "llsingleton.h" + +#include +#include +#include + +class LLLocalAssetPaths : public LLSingleton +{ + LLSINGLETON_EMPTY_CTOR(LLLocalAssetPaths); + +public: + enum EType { TYPE_MESH = 0, TYPE_ANIM, TYPE_TEXTURE, TYPE_MATERIAL }; + + // Read the saved paths (WITHOUT decoding the files), prune any that have gone + // missing, and watch the managers so newly-loaded files are remembered. Call once + // the per-account dir is valid (login); a second call is a no-op. + void loadAndWatch(); + + // Saved file paths of a type (decoded or not) -- what the floater lists. + LLSD getPaths(EType type) const; + // Forget a path (the user removed it from the floater). + void removePath(EType type, const std::string& path); + // Persisted "include joint positions" flag for a mesh path (default false), + // applied when the mesh is (lazily) decoded. + bool getMeshJoints(const std::string& path) const; + +private: + void onUnitsChanged(); // remember any newly-loaded files + void reloadForAccount(); // (re-)read the current account's saved paths + prune + static const char* key(EType type); + std::vector currentFiles(EType type) const; + std::string getFilePath() const; + void writeToDisk() const; + + LLSD mPaths; // { meshes:[...], anims:[...], textures:[...], materials:[...] } + bool mWatching = false; // loadAndWatch() runs once per session + + std::vector mConnections; +}; + +#endif // LL_LLLOCALASSETPATHS_H diff --git a/indra/newview/lllocalbitmaps.cpp b/indra/newview/lllocalbitmaps.cpp index 8bad6e6232..94215b0a53 100644 --- a/indra/newview/lllocalbitmaps.cpp +++ b/indra/newview/lllocalbitmaps.cpp @@ -1089,6 +1089,10 @@ bool LLLocalBitmapMgr::addUnit(const std::vector& filenames) iter++; } mTimer.startTimer(); + if (add_successful) + { + mUnitsChangedSignal(); + } return add_successful; } @@ -1097,11 +1101,39 @@ LLUUID LLLocalBitmapMgr::addUnit(const std::string& filename) mTimer.stopTimer(); LLUUID tracking_id = addUnitInternal(filename); mTimer.startTimer(); + if (tracking_id.notNull()) + { + mUnitsChangedSignal(); + } + return tracking_id; +} + +LLUUID LLLocalBitmapMgr::addUnit(const std::string& filename, bool mesh_owned) +{ + mTimer.stopTimer(); + LLUUID tracking_id = addUnitInternal(filename, mesh_owned); + mTimer.startTimer(); + if (tracking_id.notNull()) + { + mUnitsChangedSignal(); + } return tracking_id; } -LLUUID LLLocalBitmapMgr::addUnitInternal(const std::string& filename) +LLUUID LLLocalBitmapMgr::addUnitInternal(const std::string& filename, bool mesh_owned) { + // No double-add: reuse an existing unit for this file. A user add (mesh_owned + // false) only matches a user unit, and a mesh import only matches a mesh-owned + // unit -- so the same file can be both a user texture and a (hidden) mesh-owned + // import without colliding, and re-adding a loaded file won't duplicate it. + for (LLLocalBitmap* unit : mBitmapList) + { + if (unit->getFilename() == filename && unit->isMeshOwned() == mesh_owned) + { + return unit->getTrackingID(); + } + } + if (!checkTextureDimensions(filename)) { return LLUUID::null; @@ -1111,6 +1143,7 @@ LLUUID LLLocalBitmapMgr::addUnitInternal(const std::string& filename) if (unit->getValid()) { + unit->setMeshOwned(mesh_owned); mBitmapList.push_back(unit); return unit->getTrackingID(); } @@ -1206,6 +1239,27 @@ void LLLocalBitmapMgr::delUnit(LLUUID tracking_id) unit = NULL; } } + mUnitsChangedSignal(); +} + +boost::signals2::connection LLLocalBitmapMgr::setUnitsChangedCallback(const std::function& cb) +{ + return mUnitsChangedSignal.connect(cb); +} + +std::vector LLLocalBitmapMgr::getFilenames() const +{ + std::vector out; + out.reserve(mBitmapList.size()); + for (const LLLocalBitmap* unit : mBitmapList) + { + if (unit->isMeshOwned()) + { + continue; // a mesh's imported texture, not part of the user's set + } + out.push_back(unit->getFilename()); + } + return out; } LLUUID LLLocalBitmapMgr::getTrackingID(const LLUUID& world_id) const @@ -1238,6 +1292,18 @@ LLUUID LLLocalBitmapMgr::getWorldID(const LLUUID &tracking_id) const return world_id; } +bool LLLocalBitmapMgr::isMeshOwned(const LLUUID& tracking_id) const +{ + for (local_list_citer iter = mBitmapList.begin(); iter != mBitmapList.end(); iter++) + { + if ((*iter)->getTrackingID() == tracking_id) + { + return (*iter)->isMeshOwned(); + } + } + return false; +} + bool LLLocalBitmapMgr::isLocal(const LLUUID &world_id) const { for (local_list_citer iter = mBitmapList.begin(); iter != mBitmapList.end(); iter++) @@ -1306,19 +1372,30 @@ void LLLocalBitmapMgr::feedScrollList(LLScrollListCtrl* ctrl) for (local_list_iter iter = mBitmapList.begin(); iter != mBitmapList.end(); iter++) { + // Model-loaded (mesh-owned) textures are shown too, tagged "(model)" + // and flagged so the UI treats them as read-only / transient. + const bool mesh_owned = (*iter)->isMeshOwned(); LLSD element; element["columns"][0]["column"] = "icon"; element["columns"][0]["type"] = "icon"; element["columns"][0]["value"] = icon_name; + std::string unit_name = (*iter)->getShortName(); + if (mesh_owned) + { + LLSD name_args; + name_args["NAME"] = unit_name; + unit_name = LLTrans::getString("LocalAssetModelOwned", name_args); + } element["columns"][1]["column"] = "unit_name"; element["columns"][1]["type"] = "text"; - element["columns"][1]["value"] = (*iter)->getShortName(); + element["columns"][1]["value"] = unit_name; LLSD data; data["id"] = (*iter)->getTrackingID(); data["type"] = (S32)LLAssetType::AT_TEXTURE; + data["mesh_owned"] = mesh_owned; element["value"] = data; ctrl->addElement(element); diff --git a/indra/newview/lllocalbitmaps.h b/indra/newview/lllocalbitmaps.h index cd526e50cb..f92bba78c7 100644 --- a/indra/newview/lllocalbitmaps.h +++ b/indra/newview/lllocalbitmaps.h @@ -51,6 +51,10 @@ class LLLocalBitmap LLUUID getTrackingID() const; LLUUID getWorldID() const; bool getValid() const; + // Imported by a local mesh for its own materials: hidden from the Textures + // tab + cross-session persistence (it reappears when the mesh re-decodes). + bool isMeshOwned() const { return mIsMeshOwned; } + void setMeshOwned(bool b) { mIsMeshOwned = b; } public: /* self update public section */ enum EUpdateType @@ -106,6 +110,7 @@ class LLLocalBitmap EExtension mExtension; ELinkStatus mLinkStatus; S32 mUpdateRetries; + bool mIsMeshOwned = false; // imported by a local mesh (see isMeshOwned) LLLocalTextureChangedSignal mChangedSignal; // Store a list of accosiated materials @@ -136,21 +141,28 @@ class LLLocalBitmapMgr : public LLSingleton public: bool addUnit(const std::vector& filenames); protected: - LLUUID addUnitInternal(const std::string& filename); + LLUUID addUnitInternal(const std::string& filename, bool mesh_owned = false); public: LLUUID addUnit(const std::string& filename); + // Add a unit imported by a local mesh: hidden from the Textures tab + persistence. + LLUUID addUnit(const std::string& filename, bool mesh_owned); LLUUID getUnitID(const std::string& filename); void delUnit(LLUUID tracking_id); bool checkTextureDimensions(std::string filename); LLUUID getTrackingID(const LLUUID& world_id) const; LLUUID getWorldID(const LLUUID &tracking_id) const; + bool isMeshOwned(const LLUUID& tracking_id) const; // imported by a local mesh bool isLocal(const LLUUID& world_id) const; std::string getFilename(const LLUUID &tracking_id) const; + std::vector getFilenames() const; // every loaded path (persistence) boost::signals2::connection setOnChangedCallback(const LLUUID tracking_id, const LLLocalBitmap::LLLocalTextureCallback& cb); void associateGLTFMaterial(const LLUUID tracking_id, LLGLTFMaterial* mat); void feedScrollList(LLScrollListCtrl* ctrl); + // Fired when the unit list changes (add/remove) so the Local Assets floater + // refreshes reactively. Distinct from the per-unit setOnChangedCallback above. + boost::signals2::connection setUnitsChangedCallback(const std::function& cb); void doUpdates(); void setNeedsRebake(); void doRebake(); @@ -159,6 +171,7 @@ class LLLocalBitmapMgr : public LLSingleton std::list mBitmapList; LLLocalBitmapTimer mTimer; bool mNeedsRebake; + boost::signals2::signal mUnitsChangedSignal; // add/remove typedef std::list::iterator local_list_iter; typedef std::list::const_iterator local_list_citer; }; diff --git a/indra/newview/lllocalgltfmaterials.cpp b/indra/newview/lllocalgltfmaterials.cpp index cb839d0f93..5d378f931c 100644 --- a/indra/newview/lllocalgltfmaterials.cpp +++ b/indra/newview/lllocalgltfmaterials.cpp @@ -39,6 +39,7 @@ #include "llnotificationsutil.h" #include "llscrolllistctrl.h" #include "lltextureentry.h" +#include "lltrans.h" // "(model)" tag for mesh-owned rows #include "lltinygltfhelper.h" #include "llviewertexture.h" @@ -242,6 +243,7 @@ bool LLLocalGLTFMaterial::loadMaterial() material_name); } + mMaterialName = material_name; // for matching a mesh face's binding name if (!material_name.empty()) { mShortName = gDirUtilp->getBaseFileName(filename_lc, true) + " (" + material_name + ")"; @@ -327,6 +329,10 @@ S32 LLLocalGLTFMaterialMgr::addUnit(const std::vector& filenames) iter++; } mTimer.startTimer(); + if (add_count > 0) + { + mUnitsChangedSignal(); + } return add_count; } @@ -341,11 +347,73 @@ S32 LLLocalGLTFMaterialMgr::addUnit(const std::string& filename, LLUUID& outID) mTimer.stopTimer(); S32 res = addUnitInternal(filename, outID); mTimer.startTimer(); + if (res > 0) + { + mUnitsChangedSignal(); + } return res; } -S32 LLLocalGLTFMaterialMgr::addUnitInternal(const std::string& filename, LLUUID& outID) +S32 LLLocalGLTFMaterialMgr::addUnit(const std::string& filename, bool mesh_owned) { + mTimer.stopTimer(); + LLUUID outID; + S32 res = addUnitInternal(filename, outID, mesh_owned); + mTimer.startTimer(); + if (res > 0) + { + mUnitsChangedSignal(); + } + return res; +} + +// Content signature of a glTF material: its factors plus, for each texture slot, +// the SOURCE image (not the per-material texture index). Exporters routinely split +// one atlas-textured material into one material per face (distinct names + distinct +// texture objects that all point at the same image); those share a signature, so we +// can import them as a single local material instead of N -- no Materials-tab +// clutter, no repeated load/verify dialogs, and one shared texture that renders on +// the first load (every face references it, so its load callback repaints them all). +static std::string gltfMaterialSignature(const tinygltf::Model& model, S32 idx) +{ + const tinygltf::Material& m = model.materials[idx]; + const tinygltf::PbrMetallicRoughness& pbr = m.pbrMetallicRoughness; + std::string s; + auto add_d = [&s](double d) { s += std::to_string(d); s += ','; }; + auto add_vec = [&](const std::vector& v) { for (double d : v) { add_d(d); } s += '|'; }; + auto add_tex = [&](int ti, int tc) + { + const int src = (ti >= 0 && ti < (int)model.textures.size()) ? model.textures[ti].source : -1; + s += std::to_string(src); s += ':'; s += std::to_string(tc); s += '|'; + }; + add_vec(pbr.baseColorFactor); + add_d(pbr.metallicFactor); add_d(pbr.roughnessFactor); s += '|'; + add_tex(pbr.baseColorTexture.index, pbr.baseColorTexture.texCoord); + add_tex(pbr.metallicRoughnessTexture.index, pbr.metallicRoughnessTexture.texCoord); + add_tex(m.normalTexture.index, m.normalTexture.texCoord); add_d(m.normalTexture.scale); s += '|'; + add_tex(m.occlusionTexture.index, m.occlusionTexture.texCoord); add_d(m.occlusionTexture.strength); s += '|'; + add_vec(m.emissiveFactor); + add_tex(m.emissiveTexture.index, m.emissiveTexture.texCoord); + s += m.alphaMode; s += '|'; add_d(m.alphaCutoff); s += (m.doubleSided ? '1' : '0'); + return s; +} + +S32 LLLocalGLTFMaterialMgr::addUnitInternal(const std::string& filename, LLUUID& outID, bool mesh_owned) +{ + // No double-add: if this file's materials are already loaded (same ownership + // class -- user vs mesh-owned), return the existing first material instead of + // loading the file's materials a second time. + for (const LLPointer& unit : mMaterialList) + { + if (unit->getFilename() == filename && unit->isMeshOwned() == mesh_owned) + { + // Return THIS unit's id; getUnitID(filename, 0) ignores ownership and + // could hand back a different (user vs mesh-owned) unit for the file. + outID = unit->getTrackingID(); + return 0; // nothing newly added + } + } + tinygltf::Model model; LLTinyGLTFHelper::loadModel(filename, model); @@ -356,17 +424,42 @@ S32 LLLocalGLTFMaterialMgr::addUnitInternal(const std::string& filename, LLUUID& } S32 loaded_materials = 0; + // Deduplicate content-identical materials within this file (see + // gltfMaterialSignature). The signature comes straight from the parsed model, + // so a duplicate is skipped BEFORE it is loaded -- no second decode, no second + // verify dialog. Every original face binding name is recorded as an alias on + // the canonical unit so a mesh face still resolves to it (getWorldIDsByName). + std::vector> by_signature; for (size_t i = 0; i < materials_in_file; i++) { - // Todo: this is rather inefficient, files will be spammed with - // separate loads and date checks, find a way to improve this. - // May be doUpdates() should be checking individual files. + const std::string signature = gltfMaterialSignature(model, static_cast(i)); + + LLLocalGLTFMaterial* canonical = nullptr; + for (const auto& entry : by_signature) + { + if (entry.first == signature) + { + canonical = entry.second; + break; + } + } + if (canonical) + { + // Identical to an already-imported material: keep this face binding name + // (empty -> "mat", matching the model loader) pointing at it. + const std::string& nm = model.materials[i].name; + canonical->addAliasName(nm.empty() ? ("mat" + std::to_string(i)) : nm); + continue; + } + LLPointer unit = new LLLocalGLTFMaterial(filename, static_cast(i)); // load material from file if (unit->updateSelf()) { + unit->setMeshOwned(mesh_owned); mMaterialList.emplace_back(unit); + by_signature.emplace_back(signature, unit.get()); if(loaded_materials == 0) { outID = unit->getTrackingID(); @@ -412,6 +505,31 @@ void LLLocalGLTFMaterialMgr::delUnit(LLUUID tracking_id) unit = NULL; } } + mUnitsChangedSignal(); +} + +boost::signals2::connection LLLocalGLTFMaterialMgr::setUnitsChangedCallback(const std::function& cb) +{ + return mUnitsChangedSignal.connect(cb); +} + +std::vector LLLocalGLTFMaterialMgr::getFilenames() const +{ + // One .gltf/.glb can hold several materials (several units); persist the file once. + std::vector out; + for (const LLPointer& unit : mMaterialList) + { + if (unit->isMeshOwned()) + { + continue; // a mesh's imported material, not part of the user's set + } + const std::string filename = unit->getFilename(); + if (std::find(out.begin(), out.end(), filename) == out.end()) + { + out.push_back(filename); + } + } + return out; } LLUUID LLLocalGLTFMaterialMgr::getUnitID(const std::string& filename, S32 index) @@ -430,6 +548,17 @@ LLUUID LLLocalGLTFMaterialMgr::getUnitID(const std::string& filename, S32 index) return LLUUID::null; } +void LLLocalGLTFMaterialMgr::getTrackingIDs(const std::string& filename, std::vector& out) +{ + for (const LLPointer& unit : mMaterialList) + { + if (unit->getFilename() == filename) + { + out.push_back(unit->getTrackingID()); + } + } +} + LLUUID LLLocalGLTFMaterialMgr::getWorldID(LLUUID tracking_id) { LLUUID world_id = LLUUID::null; @@ -459,6 +588,18 @@ bool LLLocalGLTFMaterialMgr::isLocal(const LLUUID world_id) return false; } +bool LLLocalGLTFMaterialMgr::isMeshOwned(const LLUUID& tracking_id) const +{ + for (const LLPointer& unit : mMaterialList) + { + if (unit->getTrackingID() == tracking_id) + { + return unit->isMeshOwned(); + } + } + return false; +} + void LLLocalGLTFMaterialMgr::getFilenameAndIndex(LLUUID tracking_id, std::string &filename, S32 &index) { filename = ""; @@ -475,6 +616,33 @@ void LLLocalGLTFMaterialMgr::getFilenameAndIndex(LLUUID tracking_id, std::string } } +void LLLocalGLTFMaterialMgr::getWorldIDsByName(const std::string& filename, std::map& out) +{ + for (local_list_iter iter = mMaterialList.begin(); iter != mMaterialList.end(); iter++) + { + LLLocalGLTFMaterial* unit = *iter; + if (unit->getFilename() != filename) + { + continue; + } + // Match the model loader's face-binding convention: an unnamed glTF material + // binds as "mat" (see LLGLTFLoader::generateMaterialName). + std::string name = unit->getMaterialName(); + if (name.empty()) + { + name = "mat" + std::to_string(unit->getIndexInFile()); + } + out[name] = unit->getWorldID(); + + // Plus any face bindings that deduplicated onto this unit at import, so every + // face of a one-material-per-face mesh resolves to this shared material. + for (const std::string& alias : unit->getAliasNames()) + { + out[alias] = unit->getWorldID(); + } + } +} + // probably shouldn't be here, but at the moment this mirrors lllocalbitmaps void LLLocalGLTFMaterialMgr::feedScrollList(LLScrollListCtrl* ctrl) { @@ -489,19 +657,30 @@ void LLLocalGLTFMaterialMgr::feedScrollList(LLScrollListCtrl* ctrl) for (local_list_iter iter = mMaterialList.begin(); iter != mMaterialList.end(); iter++) { + // Model-loaded (mesh-owned) materials are shown too, tagged "(model)" + // and flagged so the UI treats them as read-only / transient. + const bool mesh_owned = (*iter)->isMeshOwned(); LLSD element; element["columns"][0]["column"] = "icon"; element["columns"][0]["type"] = "icon"; element["columns"][0]["value"] = icon_name; + std::string unit_name = (*iter)->getShortName(); + if (mesh_owned) + { + LLSD name_args; + name_args["NAME"] = unit_name; + unit_name = LLTrans::getString("LocalAssetModelOwned", name_args); + } element["columns"][1]["column"] = "unit_name"; element["columns"][1]["type"] = "text"; - element["columns"][1]["value"] = (*iter)->getShortName(); + element["columns"][1]["value"] = unit_name; LLSD data; data["id"] = (*iter)->getTrackingID(); data["type"] = (S32)LLAssetType::AT_MATERIAL; + data["mesh_owned"] = mesh_owned; element["value"] = data; ctrl->addElement(element); diff --git a/indra/newview/lllocalgltfmaterials.h b/indra/newview/lllocalgltfmaterials.h index 5a68681a9a..7ec1309e64 100644 --- a/indra/newview/lllocalgltfmaterials.h +++ b/indra/newview/lllocalgltfmaterials.h @@ -30,7 +30,9 @@ #include "lleventtimer.h" #include "llpointer.h" #include "llgltfmateriallist.h" +#include #include +#include class LLScrollListCtrl; class LLGLTFMaterial; @@ -49,6 +51,17 @@ class LLLocalGLTFMaterial : public LLFetchedGLTFMaterial LLUUID getTrackingID() const; LLUUID getWorldID() const; S32 getIndexInFile() const; + std::string getMaterialName() const { return mMaterialName; } // glTF material name (face binding) + // Other glTF material names (face bindings) that deduplicated onto this unit: + // a mesh import collapses content-identical materials to one, but every + // original face still binds by its own name, so we keep them for the name->id + // map (see LLLocalGLTFMaterialMgr::getWorldIDsByName). + const std::vector& getAliasNames() const { return mAliasNames; } + void addAliasName(const std::string& name) { mAliasNames.push_back(name); } + // Imported by a local mesh for its own faces: hidden from the Materials tab + + // cross-session persistence (it reappears when the mesh re-decodes). + bool isMeshOwned() const { return mIsMeshOwned; } + void setMeshOwned(bool b) { mIsMeshOwned = b; } public: bool updateSelf(); @@ -79,6 +92,9 @@ class LLLocalGLTFMaterial : public LLFetchedGLTFMaterial ELinkStatus mLinkStatus; S32 mUpdateRetries; S32 mMaterialIndex; // Single file can have more than one + std::string mMaterialName; // glTF material name, for matching a mesh face's binding + std::vector mAliasNames; // other face bindings deduplicated onto this unit + bool mIsMeshOwned = false; // imported by a local mesh (see isMeshOwned) }; class LLLocalGLTFMaterialTimer : public LLEventTimer @@ -102,22 +118,37 @@ class LLLocalGLTFMaterialMgr : public LLSingleton S32 addUnit(const std::vector& filenames); S32 addUnit(const std::string& filename); // file can hold multiple materials S32 addUnit(const std::string& filename, LLUUID& outID); // returns first material id as outID + // Add a file's materials as mesh-owned: hidden from the Materials tab + persistence. + S32 addUnit(const std::string& filename, bool mesh_owned); protected: - S32 addUnitInternal(const std::string& filename, LLUUID& outID); // file can hold multiple materials + S32 addUnitInternal(const std::string& filename, LLUUID& outID, bool mesh_owned = false); // file can hold multiple materials public: void delUnit(LLUUID tracking_id); LLUUID getUnitID(const std::string& filename, S32 index = 0); + // Tracking ids of every unit loaded from a file, in list order. Unlike walking + // getUnitID(file, 0..N), this is safe across the index gaps that import-time + // deduplication leaves in getIndexInFile(). + void getTrackingIDs(const std::string& filename, std::vector& out); LLUUID getWorldID(LLUUID tracking_id); + bool isMeshOwned(const LLUUID& tracking_id) const; // imported by a local mesh bool isLocal(LLUUID world_id); void getFilenameAndIndex(LLUUID tracking_id, std::string &filename, S32 &index); + // Map each of a file's materials by name (empty name -> "mat", matching the + // model loader's face bindings) to its world id, for texturing a local mesh. + void getWorldIDsByName(const std::string& filename, std::map& out); + std::vector getFilenames() const; // distinct loaded files (persistence) void feedScrollList(LLScrollListCtrl* ctrl); + // Fired when the unit list changes (add/remove) so the Local Assets floater + // refreshes reactively. + boost::signals2::connection setUnitsChangedCallback(const std::function& cb); void doUpdates(); private: std::list > mMaterialList; LLLocalGLTFMaterialTimer mTimer; + boost::signals2::signal mUnitsChangedSignal; // add/remove typedef std::list >::iterator local_list_iter; }; diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp new file mode 100644 index 0000000000..5a815f6d3b --- /dev/null +++ b/indra/newview/lllocalmesh.cpp @@ -0,0 +1,2165 @@ +/** + * @file lllocalmesh.cpp + * @brief Local Mesh preview source + * + * $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 "lllocalmesh.h" + +#include // std::sort (stable rez-order listing of spawned copies) +#include // mSpawnedCopies: instance id -> copy (O(1) per-copy lookup/erase) +#include // dedup avatars when rebuilding overrides across copies + +/* model loaders (shared with the mesh upload path) */ +#include "lldaeloader.h" +#include "gltf/llgltfloader.h" +#include "lljointdata.h" +#include "llskinningutil.h" + +/* geometry */ +#include "llmatrix4a.h" +#include "llquaternion.h" // linkset root rotation across reload +#include "llvolume.h" +#include "llvolumemgr.h" // LLVolumeLODGroup + +/* viewer */ +#include "fsyspath.h" +#include "indra_constants.h" // IMG_DEFAULT +#include "llagent.h" +#include "llanimationstates.h" // ANIM_AGENT_STAND (preview avatar) +#include "llcallbacklist.h" // doOnIdleOneTime +#include "llinventoryicon.h" +#include "lllocalbitmaps.h" // import a mesh's diffuse maps as mesh-owned local bitmaps +#include "lllocalgltfmaterials.h" // import a glTF mesh's materials as mesh-owned local materials +#include "llprimitive.h" // LL_PCODE_VOLUME +#include "object_flags.h" // FLAGS_OBJECT_* for owner permissions +#include "llnotificationsutil.h" // warn when a loaded mesh has no UVs +#include "llscrolllistctrl.h" +#include "llselectmgr.h" // deselect before re-parenting onto the avatar +#include "llviewercontrol.h" +#include "llviewerobjectlist.h" +#include "llviewerregion.h" +#include "lltrans.h" +#include "llviewerjointattachment.h" +#include "llvoavatarself.h" +#include "llvovolume.h" +#include "lltextureentry.h" +#include "llgltfmaterial.h" +#include "pipeline.h" + +/*=======================================*/ +/* Async load plumbing */ +/*=======================================*/ +namespace +{ + // Per-load context handed to the model loader as its opaque user data. It + // owns the by-reference joint maps the loader holds for the duration of the + // parse, so they outlive the (possibly deleted) LLLocalMesh. The load + // callback frees it. The unit is looked up by tracking ID, so a unit + // deleted mid-load can't be dereferenced. + struct LoadContext + { + LLUUID mTrackingID; + LLModelLoader* mLoader = nullptr; + JointTransformMap mJointTransformMap; + JointNameSet mJointsFromNode; + U32 mLoadState = LLModelLoader::STARTING; + LLVOAvatar* mAvatar = nullptr; // preview skeleton for joint lookup (never the agent) + }; + + // Build the joint alias map the loaders use to recognise rig joints, + // mirroring LLModelPreview::getJointAliases(). Resolved against the preview + // avatar (never the agent). + void buildJointAliases(JointMap& joint_map, LLVOAvatar* av) + { + if (!av) + { + return; + } + + joint_map = av->getJointAliases(); + + std::vector cv_names, attach_names; + av->getSortedJointNames(1, cv_names); + av->getSortedJointNames(2, attach_names); + for (const std::string& name : cv_names) + { + joint_map[name] = name; + } + for (const std::string& name : attach_names) + { + joint_map[name] = name; + } + } + + // Decompose an instance/scene transform into TRS for the preview object, mirroring + // LLMeshUploadThread::decomposeMeshMatrix(): scale = transformed basis lengths, + // rotation = orthogonalized basis (so shear is dropped, exactly as an upload would), + // position = transformed origin. Applying this as the object's transform -- instead + // of baking it into the geometry -- keeps the geometry model-local, so cacheOptimize() + // generates normals/tangents in the same frame the upload/download path does. + void decomposeInstanceMatrix(const LLMatrix4& t, LLVector3& pos, LLQuaternion& rot, LLVector3& scale) + { + const bool reflected = (t.determinant() < 0.f); + pos = LLVector3(0.f, 0.f, 0.f) * t; + LLVector3 x = LLVector3(1.f, 0.f, 0.f) * t - pos; + LLVector3 y = LLVector3(0.f, 1.f, 0.f) * t - pos; + LLVector3 z = LLVector3(0.f, 0.f, 1.f) * t - pos; + scale.set(x.normalize(), y.normalize(), z.normalize()); + if (reflected) + { + x *= -1.f; // keep a right-handed basis for the quaternion; scale stays positive + } + LLMatrix3 rm; + rm.setRows(x, y, z); + rot = rm.quaternion(); + rot.normalize(); + } + + // Loader-built models carry skin weights in mSkinWeights (a position-keyed + // map), NOT in face.mWeights -- the per-vertex array is only filled when a + // mesh is *downloaded* (LLVolume::unpackVolumeFaces). The upload path + // serializes weights by looking each vertex position up via + // getJointInfluences(); reproduce that here and pack the result exactly as a + // download would: each component is jointIndex + weight, the weight + // U16-quantized then clamped to [0.001, 0.999], up to 4 influences, with a + // (joint 0, ~1.0) fallback for any unweighted vertex. Must run BEFORE + // cacheOptimize() so its vertex remap carries the weights along. Without this + // the rigged draw path skins the mesh to garbage (holes, exploded verts, + // avatar deformation, vanishing parts). The per-vertex lookup goes through an + // LLModel::JointWeightCache (O(1)) instead of getJointInfluences() (O(V)), + // turning this pass from O(V^2) to O(V) -- see the cache's header doc. + void populateFaceWeights(LLVolumeFace& face, LLModel& mdl, const LLModel::JointWeightCache& locator) + { + if (face.mNumVertices <= 0 || mdl.mSkinWeights.empty()) + { + return; + } + face.allocateWeights(face.mNumVertices); + if (!face.mWeights) + { + return; + } + for (S32 v = 0; v < face.mNumVertices; ++v) + { + const LLVector3 pos(face.mPositions[v].getF32ptr()); + const LLModel::weight_list& weights = locator.influences(pos); + + F32 packed[4] = { 0.f, 0.f, 0.f, 0.f }; + S32 cur = 0; + F32 wsum = 0.f; + for (LLModel::weight_list::const_iterator it = weights.begin(); + it != weights.end() && cur < 4; ++it) + { + if (it->mJointIdx < 0 || it->mJointIdx >= 255) + { + continue; // matches LLModel::writeModel()'s joint-index guard + } + const U16 influence = (U16)(it->mWeight * 65535.f); + const F32 w = llclamp((F32)influence / 65535.f, 0.001f, 0.999f); + packed[cur] = (F32)it->mJointIdx + w; + wsum += w; + ++cur; + } + if (cur == 0 || wsum <= 0.f) + { + packed[0] = 0.999f; // joint 0 at full weight + } + face.mWeights[v].loadua(packed); + } + } + + // LLModelLoader::joint_lookup_func_t -- resolve a joint name against this + // load's preview avatar. NOT the agent: the DAE loader writes the model's + // joint-position overrides straight onto the returned joint, which would + // otherwise deform the user's own avatar. + LLJoint* lookupJoint(const std::string& name, void* opaque) + { + LoadContext* ctx = static_cast(opaque); + return (ctx && ctx->mAvatar) ? ctx->mAvatar->getJoint(name) : nullptr; + } + + // LLModelLoader::state_callback_t -- record the last state for diagnostics. + void onLoadState(U32 state, void* opaque) + { + if (LoadContext* ctx = static_cast(opaque)) + { + ctx->mLoadState = state; + } + } + + // Emit a one-line summary of a unit's decoded geometry. + void logUnit(const char* verb, const LLLocalMesh* unit) + { + LL_INFOS("LocalMesh") << verb << " local mesh '" << unit->getShortName() << "': " + << unit->getNumParts() << " part(s), " << unit->getNumFaces() << " faces, " + << unit->getNumVertices() << " verts, " << unit->getNumTriangles() << " tris, " + << (unit->isRigged() ? llformat("rigged (%d joints)", unit->getNumJoints()) : std::string("static")) + << LL_ENDL; + } + + // LLModelLoader::load_callback_t -- runs on the main thread once parsing is + // done. Hands the result to the manager (which assembles, spawns or swaps + // geometry) and reaps the loader's context (deferred, since we are inside the + // loader's own callback and it self-deletes after we return). + void onModelLoaded(LLModelLoader::scene& scene, LLModelLoader::model_list& /*models*/, S32 /*lod*/, void* opaque) + { + LoadContext* ctx = static_cast(opaque); + if (!ctx) + { + return; + } + + if (LLLocalMeshMgr* mgr = LLLocalMeshMgr::instanceExists() ? LLLocalMeshMgr::getInstance() : nullptr) + { + mgr->onLoadResult(ctx->mTrackingID, scene, ctx->mLoadState); + } + + // The model loader deletes itself in LLModelLoader::loadModelCallback + // once this callback returns (it waits for its thread to stop, then + // `delete this`). So we must NOT shut it down or delete it here -- that + // was a double free. Free only our context, deferred so it outlives the + // loader's self-deletion (the loader holds references into the + // context's joint maps). + doOnIdleOneTime([ctx]() { delete ctx; }); + } +} + +/*=======================================*/ +/* LLLocalMesh: unit class */ +/*=======================================*/ +LLLocalMesh::LLLocalMesh(std::string filename, bool include_joints) + : mFilename(filename) + , mShortName(gDirUtilp->getBaseFileName(filename, true)) + , mFormat(FMT_NONE) + , mState(ST_LOADING) + , mSpawnWhenReady(false) + , mIncludeJointPositions(include_joints) + , mLastModified() + , mNumFaces(0) + , mNumVertices(0) + , mNumTriangles(0) + , mNumJoints(0) + , mReloading(false) + , mPendingModified() + , mFailedModified() +{ + mTrackingID.generate(); + + std::string ext = gDirUtilp->getExtension(mFilename); + if (ext == "dae") + { + mFormat = FMT_DAE; + } + else if (ext == "gltf" || ext == "glb") + { + mFormat = FMT_GLTF; + } + else + { + LL_WARNS("LocalMesh") << "Unsupported extension for local mesh, aborting: " << mFilename << LL_ENDL; + mState = ST_FAILED; + return; + } + + if (!isAgentAvatarValid()) + { + // Joint lookups and (for glTF) the rest skeleton come from the agent + // avatar, so we need a valid avatar before parsing. + LL_WARNS("LocalMesh") << "Cannot load local mesh before the avatar is ready: " << mFilename << LL_ENDL; + mState = ST_FAILED; + return; + } + + if (!gDirUtilp->fileExists(mFilename)) + { + LL_WARNS("LocalMesh") << "Local mesh file not found: " << mFilename << LL_ENDL; + mState = ST_FAILED; + return; + } + + startLoad(); +} + +LLLocalMesh::~LLLocalMesh() +{ + // A load may still be in flight. The model loader self-deletes in + // LLModelLoader::loadModelCallback, and its callback looks this unit up by + // ID (finding nothing once we're gone), so there is nothing to clean up + // here. +} + +void LLLocalMesh::startLoad() +{ + // Record the mtime we are about to parse; the completion handler stamps it as + // the loaded version, so live-reload change detection compares against the + // exact bytes we read (not whatever the file becomes mid-parse). + std::error_code ec; + mPendingModified = std::filesystem::last_write_time(fsyspath(mFilename), ec); + + LoadContext* ctx = new LoadContext(); + ctx->mTrackingID = mTrackingID; + + // Resolve joints against a dedicated UI avatar -- never the agent. The DAE + // loader writes the model's joint-position overrides onto the looked-up + // joints, so gAgentAvatarp here would deform the user's avatar. + LLVOAvatar* preview_av = LLLocalMeshMgr::getInstance()->getPreviewAvatar(); + ctx->mAvatar = preview_av; + + JointMap joint_alias_map; + buildJointAliases(joint_alias_map, preview_av); + + LLModelLoader::load_callback_t load_cb = onModelLoaded; + LLModelLoader::joint_lookup_func_t joint_cb = lookupJoint; + LLModelLoader::texture_load_func_t texture_cb = [](LLImportMaterial&, void*) -> U32 { return 0; }; + LLModelLoader::state_callback_t state_cb = onLoadState; + + if (mFormat == FMT_DAE) + { + ctx->mLoader = new LLDAELoader( + mFilename, + LLModel::LOD_HIGH, + load_cb, + joint_cb, + texture_cb, + state_cb, + ctx, + ctx->mJointTransformMap, + ctx->mJointsFromNode, + joint_alias_map, + LLSkinningUtil::getMaxJointCount(), + gSavedSettings.getU32("ImporterModelLimit"), + gSavedSettings.getU32("ImporterDebugMode"), + gSavedSettings.getBOOL("ImporterPreprocessDAE")); + } + else // FMT_GLTF + { + std::vector viewer_skeleton; + if (preview_av) + { + preview_av->getJointMatricesAndHierarhy(viewer_skeleton); + } + ctx->mLoader = new LLGLTFLoader( + mFilename, + LLModel::LOD_HIGH, + load_cb, + joint_cb, + texture_cb, + state_cb, + ctx, + ctx->mJointTransformMap, + ctx->mJointsFromNode, + joint_alias_map, + LLSkinningUtil::getMaxJointCount(), + gSavedSettings.getU32("ImporterModelLimit"), + gSavedSettings.getU32("ImporterDebugMode"), + viewer_skeleton); + } + + ctx->mLoader->mTrySLM = false; + ctx->mLoader->start(); // parse on the worker thread; onModelLoaded fires on the main thread, then the loader self-deletes +} + +namespace +{ + // Record a mesh-owned unit referenced by the current parse (deduped). + void track_owned(std::vector& owned, const LLUUID& id) + { + if (id.notNull() && std::find(owned.begin(), owned.end(), id) == owned.end()) + { + owned.push_back(id); + } + } + + // Release (delUnit) every id in `had` that the current parse no longer keeps and + // that no sibling mesh still references (so a shared texture/material survives). + template + void release_dropped(Mgr* mgr, const std::vector& had, const std::vector& keep, + const LLLocalMesh* exclude) + { + for (const LLUUID& id : had) + { + if (std::find(keep.begin(), keep.end(), id) == keep.end() + && !LLLocalMeshMgr::getInstance()->isImportOwnedByOther(id, exclude)) + { + mgr->delUnit(id); + } + } + } +} + +LLUUID LLLocalMesh::registerOwnedBitmap(const std::string& filename, std::vector& owned) +{ + LLLocalBitmapMgr* mgr = LLLocalBitmapMgr::getInstance(); + // Reuse an existing unit for this file (another face of this mesh, a prior + // reload, or one the user already loaded) so we don't duplicate it. + LLUUID tracking_id = mgr->getUnitID(filename); + if (tracking_id.isNull()) + { + tracking_id = mgr->addUnit(filename, /*mesh_owned=*/true); + } + // Keep mesh-owned units we reference this parse; a reused USER unit is left + // untracked (the user owns it, and we must not release it on reload/delete). + if (tracking_id.notNull() && mgr->isMeshOwned(tracking_id)) + { + track_owned(owned, tracking_id); + } + return mgr->getWorldID(tracking_id); // null if the image failed to load +} + +void LLLocalMesh::importGLTFMaterials(std::map& out_by_name, std::vector& owned) +{ + LLLocalGLTFMaterialMgr* mgr = LLLocalGLTFMaterialMgr::getInstance(); + + // Load the file's materials once as mesh-owned, unless a prior parse already did. + // (A user-loaded copy from the Materials tab is a separate, non-mesh-owned set + // and doesn't count.) Import-time dedup can leave gaps in per-file material + // indices, so enumerate units by tracking id rather than walking indices. + std::vector tids; + mgr->getTrackingIDs(mFilename, tids); + bool has_mesh_owned = false; + for (const LLUUID& tid : tids) + { + if (mgr->isMeshOwned(tid)) { has_mesh_owned = true; break; } + } + if (!has_mesh_owned) + { + mgr->addUnit(mFilename, /*mesh_owned=*/true); + tids.clear(); + mgr->getTrackingIDs(mFilename, tids); + } + + // Keep this parse's mesh-owned units referenced (a user-loaded copy is left + // untracked -- the user owns it; we must not release it on reload/delete). + for (const LLUUID& tid : tids) + { + if (mgr->isMeshOwned(tid)) + { + track_owned(owned, tid); + } + } + mgr->getWorldIDsByName(mFilename, out_by_name); +} + +bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) +{ + // Build into locals first; members are only overwritten once we know the + // parse produced geometry, so a failed reload keeps showing the last good + // mesh. Each LLModel becomes its own part (<= 8 faces) -- exactly the split + // the upload path makes -- so a >8-face or multi-node file spawns as a + // linkset instead of dropping geometry. + // + // A *rigged* unit keeps the loader's geometry and skin verbatim: the loader + // already normalized the mesh to a unit box and built a matching bind-shape + // matrix, so re-normalizing or baking the instance transform here would desync + // the skinning once the preview is attached to an avatar. Static units bake + // the instance transforms and normalize for in-world linkset placement. + bool unit_rigged = false; + for (auto iter = scene.begin(); iter != scene.end() && !unit_rigged; ++iter) + { + for (LLModelInstance& instance : iter->second) + { + LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); + if (mdl && !mdl->mSkinInfo.mJointNames.empty()) + { + unit_rigged = true; + break; + } + } + } + + std::vector parts; + S32 num_vertices = 0, num_triangles = 0, num_joints = 0; + // A mesh authored without UVs can't be textured: the loaders zero-fill the + // missing texcoords, so every vertex lands at (0,0) and diffuse/material maps + // (and the face-select overlay) sample a single texel -> the surface renders + // white. Detect it so the user gets told instead of a silent white mesh. + bool unit_has_uvs = false; + + // glTF: import the file's materials once (mesh-owned) and map them by binding + // name, so each face can be pointed at the matching local-gltf material below. + // Mesh-owned imports referenced by THIS parse. On commit we release any the + // previous parse used but this one dropped (e.g. a material removed in an edit), + // so reloads don't accumulate stale local textures/materials. + std::vector new_owned_bitmaps; + std::vector new_owned_materials; + + std::map gltf_mat_by_name; + if (mFormat == FMT_GLTF) + { + importGLTFMaterials(gltf_mat_by_name, new_owned_materials); + } + + for (auto iter = scene.begin(); iter != scene.end(); ++iter) + { + for (LLModelInstance& instance : iter->second) + { + LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); + if (!mdl || mdl->getNumVolumeFaces() <= 0) + { + continue; + } + + std::vector faces; + faces.reserve(mdl->getNumVolumeFaces()); + // Built once per model (empty/cheap for static meshes): O(1) per-vertex + // weight lookup so the rigged weight pass below is O(V), not O(V^2). + const LLModel::JointWeightCache weight_locator(*mdl); + for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) + { + LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy + // Keep the loader's model-local geometry for both rigged and static + // units; the instance transform is applied as the object transform + // below (mirroring the upload), so geometry/normals/tangents stay in + // the authored frame. + if (unit_rigged) + { + // Rigged: the loader leaves face.mWeights empty (weights live + // in mSkinWeights). Fill them so the injected volume skins to + // the avatar the same way a downloaded rigged mesh would. + populateFaceWeights(face, *mdl, weight_locator); + } + num_vertices += face.mNumVertices; + num_triangles += face.mNumIndices / 3; + if (!unit_has_uvs && face.mTexCoords) + { + for (S32 v = 0; v < face.mNumVertices; ++v) + { + if (face.mTexCoords[v].mV[0] != 0.f || face.mTexCoords[v].mV[1] != 0.f) + { + unit_has_uvs = true; + break; + } + } + } + faces.push_back(face); + } + if (faces.empty()) + { + continue; + } + + LLLocalMeshPart part; + part.mWorldID.generate(); + part.mNumFaces = (S32)faces.size(); + part.mName = !instance.mLabel.empty() ? instance.mLabel : mdl->getName(); + + // Capture each face's material (M7.2). faces[fi] lines up 1:1 with + // mdl->mMaterialList[fi] (cacheOptimize keeps face order), so a flat + // per-face vector is the minimal representation. For Blinn-Phong (.dae) + // register the diffuse image as a mesh-owned local bitmap and remember + // its world id + color; glTF is handled via the material manager (M7.4), + // so its faces fall through to the default texture here. + part.mFaceMaterials.resize(faces.size()); + if (mFormat == FMT_DAE) + { + for (S32 fi = 0; fi < (S32)faces.size() && fi < (S32)mdl->mMaterialList.size(); ++fi) + { + material_map::const_iterator mit = instance.mMaterial.find(mdl->mMaterialList[fi]); + if (mit == instance.mMaterial.end()) + { + continue; + } + const LLImportMaterial& im = mit->second; + LLLocalMeshFaceMaterial& fm = part.mFaceMaterials[fi]; + fm.mDiffuseColor = im.mDiffuseColor; + fm.mFullbright = im.mFullbright; + if (!im.mDiffuseMapFilename.empty()) + { + fm.mDiffuseID = registerOwnedBitmap(im.mDiffuseMapFilename, new_owned_bitmaps); + } + } + } + else if (mFormat == FMT_GLTF && !gltf_mat_by_name.empty()) + { + for (S32 fi = 0; fi < (S32)faces.size() && fi < (S32)mdl->mMaterialList.size(); ++fi) + { + std::map::const_iterator it = + gltf_mat_by_name.find(mdl->mMaterialList[fi]); + if (it != gltf_mat_by_name.end()) + { + part.mFaceMaterials[fi].mRenderMaterialID = it->second; + } + } + } + + // Both rigged and static units keep the loader's model-local geometry and + // its mNormalizedScale (authored size); cacheOptimize() below uses it to + // reconstruct the authored frame for tangent generation exactly as the + // download path does, so normals AND tangents match a real upload. + LLVector3 nscale, ntrans; + mdl->getNormalizedScaleTranslation(nscale, ntrans); + if (unit_rigged) + { + // Placement comes from the rig once attached: no object transform. + part.mScale = nscale; + part.mRotation = LLQuaternion(); + part.mOffset = LLVector3::zero; + } + else + { + // Static: decompose the instance transform into the preview object's + // scale / rotation / position (drops shear, like the uploader's + // decomposeMeshMatrix). Geometry stays model-local, so the object + // transform reproduces the instance placement while tangents/normals + // generate in the authored frame -- identical to the uploaded copy. + LLVector3 pos, scl; + LLQuaternion rot; + decomposeInstanceMatrix(instance.mTransform, pos, rot, scl); + part.mScale = scl; + part.mRotation = rot; + part.mOffset = pos; // scene-space; spawn uses per-part differences + } + + LLVolumeParams vparams; + vparams.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + part.mVolume = new LLVolume(vparams, 1.f); + part.mVolume->copyFacesFrom(faces); + // cacheOptimize generates tangents (loaded mesh assets require them + // and raycast picking dereferences them) -- before setMeshAssetLoaded. + // Mirrors the download path (LLVolume::unpackVolumeFacesInternal). + if (!part.mVolume->cacheOptimize(true)) + { + LL_WARNS("LocalMesh") << "cacheOptimize failed for '" << mShortName << "'" << LL_ENDL; + } + part.mVolume->setMeshAssetLoaded(true); + + if (!mdl->mSkinInfo.mJointNames.empty()) + { + // Owned copy bound to this part's world id. By default include_joints + // is false: it strips the alt-inverse-bind matrices (joint-position + // overrides) + pelvis offset while keeping joint names, inverse bind, + // and bind shape -- everything skinning needs. That mirrors the + // mesh-upload preview default (show_joint_overrides off): the overrides + // reshape the avatar skeleton on attach, and a mesh weighted for the + // default skeleton (the common case) scrunches when they're applied. + // Fitted bodies that DO need the overrides opt in via the mesh tab's + // "Joint Positions" toggle (mIncludeJointPositions). + LLSD sd = mdl->mSkinInfo.asLLSD(mIncludeJointPositions, false); + part.mSkinInfo = new LLMeshSkinInfo(part.mWorldID, sd); + num_joints = llmax(num_joints, (S32)mdl->mSkinInfo.mJointNames.size()); + } + + parts.push_back(part); + } + } + + if (parts.empty()) + { + // Failed parse: drop only the units this attempt newly created, keeping the + // old owned set and the last-good geometry. + release_dropped(LLLocalBitmapMgr::getInstance(), new_owned_bitmaps, mOwnedBitmaps, this); + release_dropped(LLLocalGLTFMaterialMgr::getInstance(), new_owned_materials, mOwnedMaterials, this); + LL_WARNS("LocalMesh") << "Local mesh produced no geometry: " << mFilename << LL_ENDL; + return false; // keep any previously loaded geometry intact + } + + // Commit. First release mesh-owned imports the previous version used but this one + // no longer does (e.g. a material/texture removed in an edit), then adopt the new + // owned sets so they're freed with the mesh later. + release_dropped(LLLocalBitmapMgr::getInstance(), mOwnedBitmaps, new_owned_bitmaps, this); + release_dropped(LLLocalGLTFMaterialMgr::getInstance(), mOwnedMaterials, new_owned_materials, this); + mOwnedBitmaps = std::move(new_owned_bitmaps); + mOwnedMaterials = std::move(new_owned_materials); + + mParts = std::move(parts); + mNumFaces = 0; + for (const LLLocalMeshPart& p : mParts) { mNumFaces += p.mNumFaces; } + mNumVertices = num_vertices; + mNumTriangles = num_triangles; + mNumJoints = num_joints; + mHasUVs = unit_has_uvs; + mState = ST_LOADED; + mLastModified = mPendingModified; // the exact version we just parsed + + return true; +} + +bool LLLocalMesh::isRigged() const +{ + for (const LLLocalMeshPart& p : mParts) + { + if (p.mSkinInfo.notNull()) + { + return true; + } + } + return false; +} + +bool LLLocalMesh::pollForReload() +{ + // Only watch units that are currently showing good geometry; skip while an + // initial load or a previous reload is still in flight. + if (mState != ST_LOADED || mReloading) + { + return false; + } + if (!gDirUtilp->fileExists(mFilename)) + { + return false; + } + + std::error_code ec; + const std::filesystem::file_time_type mtime = std::filesystem::last_write_time(fsyspath(mFilename), ec); + if (ec || mtime == mLastModified || mtime == mFailedModified) + { + return false; // unchanged, or a version we already know fails to parse + } + + mReloading = true; + LL_INFOS("LocalMesh") << "Source changed, reloading '" << mShortName << "'" << LL_ENDL; + startLoad(); // captures mPendingModified + return true; +} + +void LLLocalMesh::finishReload(bool ok) +{ + mReloading = false; + if (ok) + { + mFailedModified = std::filesystem::file_time_type(); // clear any prior failure + } + else + { + // Remember this version failed so we don't re-attempt it every tick; a + // further edit (a new mtime) will be retried. + mFailedModified = mPendingModified; + LL_WARNS("LocalMesh") << "Reload parse failed for '" << mShortName << "'; keeping previous geometry" << LL_ENDL; + } +} + +bool LLLocalMesh::forceReload() +{ + // Re-parse the current file regardless of mtime so a changed build option (e.g. + // the joint-position toggle) takes effect. Reuses the reload path: onLoadResult() + // rebuilds the geometry and refreshes the in-world linkset if one exists. + if (mReloading || !gDirUtilp->fileExists(mFilename)) + { + return false; + } + mReloading = true; + startLoad(); // captures mPendingModified + return true; +} + +/*=======================================*/ +/* LLLocalMeshMgr: manager class */ +/*=======================================*/ +LLLocalMeshMgr::LLLocalMeshMgr() +{ + mTimer.stopTimer(); // started on demand once the first unit is added +} + +LLLocalMeshMgr::~LLLocalMeshMgr() +{ + cleanup(); // release spawned objects + preview avatar (idempotent) + + for (LLLocalMesh* unit : mMeshList) + { + delete unit; + } + mMeshList.clear(); +} + +void LLLocalMeshMgr::cleanup() +{ + // Release every client-only object we rezzed and the preview avatar. Called + // from LLViewerObjectList::killAllObjects() so they die while the object list + // and regions are still valid, rather than later when this singleton is torn + // down. markDead() is idempotent, so the kill loop re-marking them is fine. + mTimer.stopTimer(); + + for (auto& entry : mSpawnedCopies) + { + for (LLPointer& p : entry.second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + detachRootIfAttached(p.get()); // unwear before it dies (only the root is worn) + p->markDead(); + } + } + } + mSpawnedCopies.clear(); + + if (mPreviewAvatar.notNull()) + { + if (!mPreviewAvatar->isDead()) + { + mPreviewAvatar->markDead(); + } + mPreviewAvatar = nullptr; + } +} + +void LLLocalMeshMgr::despawnObjectsInRegion(LLViewerRegion* regionp) +{ + // The hosting region is being torn down (LLViewerObjectList::killObjects). + // Release our client-only objects in it so they don't dangle past their + // region; the loaded units stay, so a later spawn/reload re-creates them in + // whatever region is current then. markDead() is idempotent. + for (auto iter = mSpawnedCopies.begin(); iter != mSpawnedCopies.end(); ) + { + SpawnedCopy& copy = iter->second; + LLViewerObject* root = copy.mPrims.empty() ? nullptr : copy.mPrims.front().get(); + // A copy is a single linkset in one region, so the root's region covers it all. + if (!root || root->isDead() || root->getRegion() == regionp) + { + for (LLPointer& p : copy.mPrims) + { + if (p.notNull() && !p->isDead()) + { + detachRootIfAttached(p.get()); // unwear before it dies + p->markDead(); + } + } + iter = mSpawnedCopies.erase(iter); + } + else + { + ++iter; + } + } + + if (mPreviewAvatar.notNull() && (mPreviewAvatar->isDead() || mPreviewAvatar->getRegion() == regionp)) + { + if (!mPreviewAvatar->isDead()) + { + mPreviewAvatar->markDead(); + } + mPreviewAvatar = nullptr; // recreated lazily in the current region on next load + } +} + +LLVOAvatar* LLLocalMeshMgr::getPreviewAvatar(bool run_stand_anim) +{ + if ((mPreviewAvatar.isNull() || mPreviewAvatar->isDead()) && gAgent.getRegion()) + { + // A dedicated, never-rendered UI avatar gives the model loaders a skeleton + // to resolve joints against -- and to absorb the model's joint-position + // overrides -- WITHOUT mutating the real agent avatar. Mirrors + // LLModelPreview::createPreviewAvatar(). + LLVOAvatar* av = (LLVOAvatar*)gObjectList.createObjectViewer(LL_PCODE_LEGACY_AVATAR, gAgent.getRegion(), LLViewerObject::CO_FLAG_UI_AVATAR); + if (av) + { + av->createDrawable(&gPipeline); + av->mSpecialRenderMode = 1; // not part of the in-world render + // Leave the skeleton at its REST pose by default. The FBX loader rebuilds missing + // inverse binds as inverse(joint world matrix), so the joints must sit at the + // canonical bind pose; the stand animation would pose the arms and skew those + // binds. Only run it if this avatar is actually being rendered. + if (run_stand_anim) + { + av->startMotion(ANIM_AGENT_STAND); + } + av->hideSkirt(); + } + else + { + LL_WARNS("LocalMesh") << "Failed to create preview avatar for joint resolution" << LL_ENDL; + } + mPreviewAvatar = av; + } + return mPreviewAvatar.get(); +} + +LLUUID LLLocalMeshMgr::addUnit(const std::string& filename, bool include_joints) +{ + return addUnitInternal(filename, include_joints); +} + +bool LLLocalMeshMgr::addUnit(const std::vector& filenames) +{ + bool any = false; + for (const std::string& filename : filenames) + { + if (!filename.empty() && addUnitInternal(filename).notNull()) + { + any = true; + } + } + return any; +} + +LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename, bool include_joints) +{ + // No double-add: a file that's already loaded just returns its existing unit + // (so it can be rezzed again rather than duplicated in the list). + if (LLUUID existing = getUnitID(filename); existing.notNull()) + { + return existing; + } + + LLLocalMesh* unit = new LLLocalMesh(filename, include_joints); + if (unit->isFailed()) + { + // Immediate failure (bad extension / no avatar / missing file). + LL_WARNS("LocalMesh") << "Could not start loading mesh file: " << filename << LL_ENDL; + delete unit; + return LLUUID::null; + } + + // Loading (async) or already loaded -- keep it; completion is handled in + // the load callback. + mMeshList.push_back(unit); + mUnitsChangedSignal(); + if (!mTimer.isRunning()) + { + mTimer.startTimer(); // begin watching source files for live reload + } + return unit->getTrackingID(); +} + +void LLLocalMeshMgr::delUnit(LLUUID tracking_id) +{ + for (local_list_iter iter = mMeshList.begin(); iter != mMeshList.end(); ) + { + LLLocalMesh* unit = *iter; + if (unit->getTrackingID() == tracking_id) + { + despawnUnit(tracking_id); + // Release the mesh-owned local bitmaps + materials imported for this + // unit -- but only the ones no OTHER loaded mesh still references. + // Imports are deduplicated by file, so two meshes using the same + // texture/material share a tracking id; releasing it unconditionally + // would strip it from the other mesh too. + auto shared_with_other = [&](const LLUUID& import_id) -> bool + { + for (const LLLocalMesh* other : mMeshList) + { + if (other == unit) + { + continue; + } + for (const LLUUID& bid : other->mOwnedBitmaps) + { + if (bid == import_id) return true; + } + for (const LLUUID& mid : other->mOwnedMaterials) + { + if (mid == import_id) return true; + } + } + return false; + }; + for (const LLUUID& bid : unit->mOwnedBitmaps) + { + if (!shared_with_other(bid)) + { + LLLocalBitmapMgr::getInstance()->delUnit(bid); + } + } + for (const LLUUID& mid : unit->mOwnedMaterials) + { + if (!shared_with_other(mid)) + { + LLLocalGLTFMaterialMgr::getInstance()->delUnit(mid); + } + } + iter = mMeshList.erase(iter); + delete unit; + } + else + { + ++iter; + } + } + + if (mMeshList.empty()) + { + mTimer.stopTimer(); // nothing left to watch + } + mUnitsChangedSignal(); +} + +LLUUID LLLocalMeshMgr::getUnitID(const std::string& filename) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getFilename() == filename) + { + return unit->getTrackingID(); + } + } + return LLUUID::null; +} + +const LLLocalMeshPart* LLLocalMeshMgr::findPart(const LLUUID& world_id) const +{ + if (world_id.isNull()) + { + return nullptr; + } + for (LLLocalMesh* unit : mMeshList) + { + for (const LLLocalMeshPart& part : unit->getParts()) + { + if (part.mWorldID == world_id) + { + return ∂ + } + } + } + return nullptr; +} + +bool LLLocalMeshMgr::isLocal(const LLUUID& world_id) const +{ + return findPart(world_id) != nullptr; +} + +LLVolume* LLLocalMeshMgr::getVolumeForWorldID(const LLUUID& world_id) const +{ + const LLLocalMeshPart* part = findPart(world_id); + return part ? part->mVolume.get() : nullptr; +} + +const LLMeshSkinInfo* LLLocalMeshMgr::getSkinInfoForWorldID(const LLUUID& world_id) const +{ + const LLLocalMeshPart* part = findPart(world_id); + return part ? part->mSkinInfo.get() : nullptr; +} + +std::string LLLocalMeshMgr::getFilename(const LLUUID& tracking_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getTrackingID() == tracking_id) + { + return unit->getFilename(); + } + } + return std::string(); +} + +LLLocalMesh* LLLocalMeshMgr::getUnit(const LLUUID& tracking_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getTrackingID() == tracking_id) + { + return unit; + } + } + return nullptr; +} + +bool LLLocalMeshMgr::isImportOwnedByOther(const LLUUID& tracking_id, const LLLocalMesh* exclude) const +{ + if (tracking_id.isNull()) + { + return false; + } + for (const LLLocalMesh* unit : mMeshList) + { + if (unit == exclude) + { + continue; + } + if (std::find(unit->mOwnedBitmaps.begin(), unit->mOwnedBitmaps.end(), tracking_id) != unit->mOwnedBitmaps.end() + || std::find(unit->mOwnedMaterials.begin(), unit->mOwnedMaterials.end(), tracking_id) != unit->mOwnedMaterials.end()) + { + return true; + } + } + return false; +} + +std::vector LLLocalMeshMgr::getFilenames() const +{ + std::vector out; + out.reserve(mMeshList.size()); + for (const LLLocalMesh* unit : mMeshList) + { + out.push_back(unit->getFilename()); + } + return out; +} + +void LLLocalMeshMgr::despawnPreviewObject(LLViewerObject* obj) +{ + if (!obj) + { + return; + } + + // Map the object (linkset root or child) back to the single rezzed copy that owns + // it and derez just that copy -- so the in-world Delete key removes one copy, not + // every copy of the unit. The loaded file stays in the list so it can be + // re-spawned, and onLoadResult won't auto-respawn it on a later file save. + despawnInstance(instanceForObject(obj)); +} + +void LLLocalMeshMgr::despawn(const LLUUID& tracking_id) +{ + if (tracking_id.notNull()) + { + despawnUnit(tracking_id); // removes the in-world linkset, keeps the unit loaded + } +} + +boost::signals2::connection LLLocalMeshMgr::setUnitsChangedCallback(const std::function& cb) +{ + return mUnitsChangedSignal.connect(cb); +} + +LLViewerObject* LLLocalMeshMgr::findRootForObject(const LLViewerObject* obj) const +{ + if (!obj) + { + return nullptr; + } + // Resolve obj (root or child) to its rezzed copy, then return that copy's root. + return getInstanceRoot(instanceForObject(obj)); +} + +LLUUID LLLocalMeshMgr::instanceForObject(const LLViewerObject* obj) const +{ + if (obj) + { + // obj may be any prim of a copy (root or child); match it within each copy. + // Only called by the infrequent per-object paths (in-world Delete, attach/ + // detach), so scanning the copies' prims is fine. + for (const auto& entry : mSpawnedCopies) + { + for (const LLPointer& p : entry.second.mPrims) + { + if (p.get() == obj) + { + return entry.first; + } + } + } + } + return LLUUID::null; +} + +bool LLLocalMeshMgr::getPreviewDisplay(const LLViewerObject* obj, std::string& name_out, std::string& path_out) const +{ + auto it = mSpawnedCopies.find(instanceForObject(obj)); + if (it == mSpawnedCopies.end()) + { + return false; + } + LLLocalMesh* unit = getUnit(it->second.mTrackingID); + if (!unit) + { + return false; + } + path_out = unit->getFilename(); + name_out = unit->getShortName(); + // One prim per part (see spawnLinkset), so the prim's index in this copy maps + // 1:1 to its part -- prefer the part's own sub-mesh name when it has one. + const std::vector>& prims = it->second.mPrims; + const std::vector& parts = unit->getParts(); + for (size_t i = 0; i < prims.size(); ++i) + { + if (prims[i].get() == obj) + { + if (i < parts.size() && !parts[i].mName.empty()) + { + name_out = parts[i].mName; + } + break; + } + } + return true; +} + +LLViewerObject* LLLocalMeshMgr::getInstanceRoot(const LLUUID& instance_id) const +{ + auto it = mSpawnedCopies.find(instance_id); + if (it != mSpawnedCopies.end() && !it->second.mPrims.empty()) + { + LLViewerObject* root = it->second.mPrims.front().get(); + if (root && !root->isDead()) + { + return root; + } + } + return nullptr; +} + +bool LLLocalMeshMgr::isRiggedPreview(const LLViewerObject* obj) const +{ + if (!obj || !obj->isLocalOnly()) + { + return false; + } + auto it = mSpawnedCopies.find(instanceForObject(obj)); + if (it != mSpawnedCopies.end()) + { + LLLocalMesh* unit = getUnit(it->second.mTrackingID); + return unit && unit->isRigged(); + } + return false; +} + +bool LLLocalMeshMgr::isPreviewAttached(const LLViewerObject* obj) const +{ + LLViewerObject* root = findRootForObject(obj); + return root && root->isAttachment(); +} + +void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj, S32 attach_point, bool replace) +{ + if (!isAgentAvatarValid()) + { + return; + } + LLViewerObject* root = findRootForObject(obj); + if (!root || root->isAttachment()) + { + return; // not one of ours, or already worn + } + + // Reproduce a server attach's end state for this client-only linkset: + // * encode the chosen attachment-point id into the state every prim carries, so + // getTargetAttachmentPoint() binds the linkset to that point and isAttachment()/ + // getAvatar() recognise them. The point matters: the avatar sorts rigged + // render order by attachment-point id, so the user picks it from the normal + // "Attach" menu. ATTACHMENT_ID_FROM_STATE is a symmetric nibble-swap, so + // applying it to the id yields the state (id 1 == chest == state 0x10). + // * make the root a child of the avatar in the object tree so getAvatar()'s + // parent walk reaches the agent (root for itself, children via the root) -- + // this is what routes the faces into the rigged draw path, + // * attachObject() to parent the drawable to the joint (recursively, including + // children). The preview skin carries no joint-position overrides (stripped + // in ingestScene to match the upload default), so the agent skeleton is left + // as-is and a default-weighted mesh renders correctly. + const LLUUID instance_id = instanceForObject(root); + const S32 point = (attach_point > 0) ? attach_point : 1; // default to chest + + // Replace (vs Add): take off any OTHER local preview already worn on this point + // first. Stays client-only and per-point -- it never touches the user's real + // attachments (those are server-side and not ours to remove). The displaced + // preview drops back in-world rather than being destroyed. + if (replace) + { + std::vector displaced; + for (const auto& entry : mSpawnedCopies) + { + if (entry.first == instance_id || entry.second.mPrims.empty()) + { + continue; + } + LLViewerObject* other = entry.second.mPrims[0]; // linkset root + if (other && !other->isDead() && other->isAttachment() + && (S32)ATTACHMENT_ID_FROM_STATE(other->getAttachmentState()) == point) + { + displaced.push_back(other); + } + } + for (LLViewerObject* other : displaced) + { + detachPreviewFromAvatar(other); + } + } + + const U8 attach_state = (U8)ATTACHMENT_ID_FROM_STATE(point); + if (auto it = mSpawnedCopies.find(instance_id); it != mSpawnedCopies.end()) + { + for (LLPointer& p : it->second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + p->setAttachmentState(attach_state); + } + } + } + + LLSelectMgr::getInstance()->deselectAll(); // dropping the in-world selection before reparenting + + // Normal attach path: addChild() makes the root a child of the avatar in the + // object tree (so getAvatar()'s parent walk reaches the agent and routes the + // rigged faces into the avatar draw path), then attaches it -- parenting the + // drawable to the joint. The LLVOAvatarSelf::attachObject() override and the RLV + // watchdog detect the client-only flag and skip the inventory/COF/RLV + // bookkeeping a preview lacks. + gAgentAvatarp->addChild(root); + + mUnitsChangedSignal(); // worn state changed -> refresh the list status + LL_INFOS("LocalMesh") << "Attached local mesh preview to avatar" << LL_ENDL; +} + +void LLLocalMeshMgr::detachPreviewFromAvatar(LLViewerObject* obj) +{ + LLViewerObject* root = findRootForObject(obj); + if (!root || !root->isAttachment() || !isAgentAvatarValid()) + { + return; + } + const LLUUID instance_id = instanceForObject(root); + + LLSelectMgr::getInstance()->deselectAll(); + + // Detach in place (NOT despawn/respawn) so the same prims -- and any edits the + // user made (textures, colours, transforms) -- survive being taken off. + // + // Clear the attachment state on this copy's WHOLE linkset FIRST: the makeStatic() + // inside LLViewerJointAttachment::removeObject() is guarded on !isAttachment(), so + // with the state still set it's a no-op and the drawables stay ACTIVE on the + // avatar's spatial bridge -- once detached they then render adrift from their + // bounding boxes (mesh in one spot, bbox across the region). Clearing first lets + // removeObject() re-home the linkset (root + children) into the region partition. + if (auto it = mSpawnedCopies.find(instance_id); it != mSpawnedCopies.end()) + { + for (LLPointer& p : it->second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + p->setAttachmentState(0); + } + } + } + + // LLVOAvatar::removeChild() clears the object-tree parent AND detaches + // (removeObject -> makeStatic, now effective), so a single call does both. + gAgentAvatarp->removeChild(root); + + // setupDrawable() parented the root drawable's xform to the attachment joint; + // restore it as a region root so its world position comes from getPositionAgent(). + if (root->mDrawable.notNull()) + { + root->mDrawable->mXform.setParent(NULL); + } + + // Put it back in-world a few metres in front of the agent. + root->setPositionAgent(gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f); + root->markForUpdate(); + + mUnitsChangedSignal(); // worn state changed -> refresh the list status +} + +void LLLocalMeshMgr::detachRootIfAttached(LLViewerObject* root) +{ + // Only the linkset root is the avatar's direct attachment; children hang off the + // root in the object tree, so skip anything that isn't worn on the avatar. + if (!root || !root->isAttachment() || !isAgentAvatarValid() + || root->getParent() != (LLViewerObject*)gAgentAvatarp) + { + return; + } + // Clear the attachment state first so the makeStatic() inside removeObject() + // runs (it's guarded on !isAttachment()), then remove from the avatar in one + // call: LLVOAvatar::removeChild() clears the object-tree parent AND detaches via + // LLVOAvatarSelf::detachObject() (local-safe, no inventory/COF/RLV traffic). + // Used by the despawn/cleanup paths right before markDead(), so -- unlike + // detachPreviewFromAvatar() -- no in-world re-home is needed. + root->setAttachmentState(0); + gAgentAvatarp->removeChild(root); +} + +LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid() || unit->getParts().empty()) + { + LL_WARNS("LocalMesh") << "spawnInWorld: no valid unit for " << tracking_id << LL_ENDL; + return nullptr; + } + + if (!isAgentAvatarValid() || !gAgent.getRegion()) + { + LL_WARNS("LocalMesh") << "spawnInWorld: agent/region not ready" << LL_ENDL; + return nullptr; + } + + // Always rez a NEW copy a few metres in front of the agent. `base` is the root + // prim's world position (root = the model centre + part 0's offset). + const std::vector& parts = unit->getParts(); + const LLVector3 base = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f + parts[0].mOffset; + LLUUID instance_id; + instance_id.generate(); + + LLViewerObject* root = spawnLinkset(tracking_id, instance_id, base, LLQuaternion(), false, 0); + if (root) + { + LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' as " + << unit->getNumParts() << " prim(s), " << unit->getNumFaces() << " faces" << LL_ENDL; + } + return root; +} + +LLViewerObject* LLLocalMeshMgr::spawnLinkset(const LLUUID& tracking_id, const LLUUID& instance_id, + const LLVector3& base, const LLQuaternion& root_rot, + bool attach, S32 attach_point) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid() || unit->getParts().empty() + || !isAgentAvatarValid() || !gAgent.getRegion()) + { + return nullptr; + } + const std::vector& parts = unit->getParts(); + + // One prim per part, linked into a single linkset rooted at the first prim -- + // the same structure an upload of this file would rez. The whole linkset is one + // copy, stored under instance_id, so it can be addressed independently of the + // unit's other copies. + SpawnedCopy copy; + copy.mTrackingID = tracking_id; + copy.mSeq = mNextSpawnSeq++; + + LLViewerObject* root = nullptr; + for (size_t i = 0; i < parts.size(); ++i) + { + const LLLocalMeshPart& part = parts[i]; + + LLViewerObject* obj = gObjectList.createObjectViewer(LL_PCODE_VOLUME, gAgent.getRegion()); + LLVOVolume* vol = dynamic_cast(obj); + if (!vol) + { + LL_WARNS("LocalMesh") << "spawnLinkset: failed to create volume object" << LL_ENDL; + if (obj) { obj->markDead(); } + continue; + } + + // Client-only object: mark it so the build/select code skips server + // traffic and deletes it locally. Selectable/movable with full owner + // permission flags so the tools enable manipulation. + vol->mbCanSelect = true; + vol->mIsLocalOnly = true; + vol->setFlagsWithoutUpdate(FLAGS_OBJECT_YOU_OWNER | FLAGS_OBJECT_MODIFY | FLAGS_OBJECT_MOVE | FLAGS_OBJECT_COPY | FLAGS_OBJECT_TRANSFER, true); + + // Build the drawable and force a valid LOD before setVolume (a NO_LOD + // drawable would make LLVOVolume::setVolume build a placeholder cube and + // skip the repository injection). See applyPartGeometry. + gPipeline.createObject(obj); + vol->setLOD(LLVolumeLODGroup::NUM_LODS - 1); + + if (root) + { + root->addChild(vol); // link into the root's linkset (sets mParent) + // Parent the drawable too, so the child renders/moves relative to the + // root. Server linksets get this in processUpdateMessage(); a + // client-only linkset must establish it explicitly. + vol->setDrawableParent(root->mDrawable); + } + else + { + root = obj; + } + + vol->setScale(part.mScale, false); + applyPartGeometry(vol, part); + + if (obj == root) + { + // Root: the part's intrinsic rotation composed with the rez orientation. + // Set it BEFORE positioning children so their world->parent-local + // conversion below accounts for it. (Rigged parts are identity rotation, + // so this reduces to the old root_rot.) + root->setRotation(part.mRotation * root_rot); + root->setPositionAgent(base); + } + else + { + // Child: world position is the intrinsic offset from the root part, + // rotated by the rez orientation; rotation is the part's intrinsic + // rotation relative to the root part (parent-local). + vol->setPositionAgent(base + (part.mOffset - parts[0].mOffset) * root_rot); + vol->setRotation(part.mRotation * ~parts[0].mRotation); + } + + copy.mPrims.push_back(obj); + } + + if (root) + { + // Store the copy before attaching -- attachPreviewToAvatar() looks it up by id. + mSpawnedCopies[instance_id] = std::move(copy); + if (attach) + { + attachPreviewToAvatar(root, attach_point); // restore a worn copy across a re-spawn + } + } + + mUnitsChangedSignal(); // rezzed state changed + return root; +} + +void LLLocalMeshMgr::respawnInstancesInPlace(const LLUUID& tracking_id) +{ + // Capture each existing copy's identity, transform and attachment, then re-rez + // them in place. Used when a reload changes the prim count (hot-swap can't swap + // 1:1), so every copy refreshes without moving or losing its worn state. + struct Saved + { + LLUUID mInstanceID; + LLVector3 mBase; + LLQuaternion mRot; + bool mAttached = false; + S32 mPoint = 0; + }; + // spawnLinkset() composes the root part's intrinsic rotation (parts[0].mRotation) + // with the root_rot argument, so save root_rot as the USER delta + // (~intrinsic * world), not the full world rotation -- otherwise the re-rez would + // apply the intrinsic rotation twice. Read it from the (already reparsed) unit. + const LLLocalMesh* unit = getUnit(tracking_id); + const LLQuaternion intrinsic0 = (unit && !unit->getParts().empty()) + ? unit->getParts().front().mRotation : LLQuaternion(); + std::vector saved; + for (const auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mTrackingID != tracking_id || copy.mPrims.empty()) + { + continue; + } + LLViewerObject* r = copy.mPrims.front().get(); // mPrims[0] is the root + if (!r || r->isDead()) + { + continue; + } + Saved s; + s.mInstanceID = entry.first; + s.mBase = r->getPositionAgent(); + s.mRot = ~intrinsic0 * r->getRotation(); // user delta, not full world rotation + s.mAttached = r->isAttachment(); + s.mPoint = s.mAttached ? ATTACHMENT_ID_FROM_STATE(r->getAttachmentState()) : 0; + saved.push_back(s); + } + + despawnUnit(tracking_id); // drop all copies; re-rez them below at the same places + + for (const Saved& s : saved) + { + spawnLinkset(tracking_id, s.mInstanceID, s.mBase, s.mRot, s.mAttached, s.mPoint); + } +} + +namespace +{ +// A face's user-visible render state, snapshotted so user edits survive a hot-swap: +// applyPartGeometry re-applies the file's imported materials and resets every face, +// which would otherwise wipe textures/materials/glow/etc. the user applied to the +// in-world preview. +struct PreservedFace +{ + LLTextureEntry te; // diffuse id, color, glow, bump/shiny/fullbright, transforms, Blinn-Phong material, glTF override + LLUUID render_mat_id; // glTF render material id (kept in the param block, not the TE) +}; + +std::vector capturePreservedFaces(LLVOVolume* vol) +{ + std::vector out; + const U8 n = vol->getNumTEs(); + out.reserve(n); + for (U8 i = 0; i < n; ++i) + { + PreservedFace pf; + if (const LLTextureEntry* tep = vol->getTE(i)) + { + pf.te = *tep; + } + pf.render_mat_id = vol->getRenderMaterialID(i); + out.push_back(pf); + } + return out; +} + +void restorePreservedFaces(LLVOVolume* vol, const std::vector& saved) +{ + const U8 n = vol->getNumTEs(); + bool any_render_mat = false; + for (U8 i = 0; i < n && i < (U8)saved.size(); ++i) + { + const LLTextureEntry& te = saved[i].te; + vol->setTETexture(i, te.getID()); + vol->setTEColor(i, te.getColor()); + vol->setTEGlow(i, te.getGlow()); + vol->setTEBumpShinyFullbright(i, te.getBumpShinyFullbright()); + F32 ss = 1.f, st = 1.f, os = 0.f, ot = 0.f; + te.getScale(&ss, &st); + te.getOffset(&os, &ot); + vol->setTEScale(i, ss, st); + vol->setTEOffset(i, os, ot); + vol->setTERotation(i, te.getRotation()); + if (te.getMaterialParams().notNull()) + { + vol->setTEMaterialParams(i, te.getMaterialParams()); // Blinn-Phong normal/specular + } + if (saved[i].render_mat_id.notNull()) + { + // glTF PBR material (+ override); client-only, so update_server=false. + vol->setRenderMaterialID((S32)i, saved[i].render_mat_id, false, true); + if (const LLGLTFMaterial* ov = te.getGLTFMaterialOverride()) + { + LLPointer ovp = new LLGLTFMaterial(*ov); + vol->setTEGLTFMaterialOverride(i, ovp); + } + any_render_mat = true; + } + } + if (any_render_mat) + { + vol->setHasRenderMaterialParams(true); + } + vol->markForUpdate(); +} +} // namespace + +bool LLLocalMeshMgr::hotSwapInWorld(const LLUUID& tracking_id) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid()) + { + return false; + } + const std::vector& parts = unit->getParts(); + + // Only structurally identical copies can be swapped 1:1; a changed prim count + // needs a real re-spawn (to add/remove prims) -- caller falls back. The copies + // are already grouped (one entry per copy), so just check each copy's prim count. + S32 copies = 0; + for (const auto& entry : mSpawnedCopies) + { + if (entry.second.mTrackingID != tracking_id) + { + continue; + } + ++copies; + if (entry.second.mPrims.size() != parts.size()) + { + return false; + } + } + if (copies == 0) + { + return false; + } + + // Point each prim at the freshly decoded geometry by swapping its mesh (sculpt) + // id. The new id has an empty volume cache, so the repository copies the new + // faces and the prim rebuilds in place; LLVOVolume also drops its cached skin + // when the mesh id changes, so rigged skinning refreshes too. No despawn -> each + // copy's attachment, selection and transform are all preserved. + for (auto& entry : mSpawnedCopies) + { + SpawnedCopy& copy = entry.second; + if (copy.mTrackingID != tracking_id) + { + continue; + } + for (size_t i = 0; i < parts.size(); ++i) + { + LLViewerObject* o = copy.mPrims[i].get(); + LLVOVolume* vol = (o && !o->isDead()) ? dynamic_cast(o) : nullptr; + if (!vol) + { + return false; // a dead/unexpected prim -> let the caller re-spawn + } + vol->setScale(parts[i].mScale, false); + // Preserve user-applied face params (diffuse/normal/specular/glow/color/ + // render material/transforms) across the swap -- applyPartGeometry resets + // every face to the file's imported material. + std::vector preserved = capturePreservedFaces(vol); + applyPartGeometry(vol, parts[i]); + restorePreservedFaces(vol, preserved); + } + } + + LL_INFOS("LocalMesh") << "Hot-swapped local mesh '" << unit->getShortName() << "' (" + << copies << " copy(ies), " << parts.size() << " prim(s) each) in place" << LL_ENDL; + return true; +} + +LLViewerObject* LLLocalMeshMgr::getSpawnedRoot(const LLUUID& tracking_id) const +{ + // Any one live copy's root -- enough for "is this unit in-world?" checks. + for (const auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mTrackingID == tracking_id && !copy.mPrims.empty()) + { + LLViewerObject* root = copy.mPrims.front().get(); + if (root && !root->isDead()) + { + return root; + } + } + } + return nullptr; +} + +S32 LLLocalMeshMgr::getSpawnedCount(const LLUUID& tracking_id) const +{ + S32 count = 0; + for (const auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mTrackingID == tracking_id && !copy.mPrims.empty() + && copy.mPrims.front().notNull() && !copy.mPrims.front()->isDead()) + { + ++count; + } + } + return count; +} + +std::vector LLLocalMeshMgr::getSpawnedInstances() const +{ + // Collect with the rez sequence so the list is stable in rez order, independent + // of the map's (unspecified) iteration order. + std::vector > ordered; + for (const auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mPrims.empty() || copy.mPrims.front().isNull() || copy.mPrims.front()->isDead()) + { + continue; + } + ordered.push_back({copy.mSeq, {entry.first, copy.mTrackingID, copy.mPrims.front().get()}}); + } + std::sort(ordered.begin(), ordered.end(), + [](const std::pair& a, const std::pair& b) + { return a.first < b.first; }); + + std::vector out; + out.reserve(ordered.size()); + for (const auto& pr : ordered) + { + out.push_back(pr.second); + } + return out; +} + +void LLLocalMeshMgr::despawnInstance(const LLUUID& instance_id) +{ + auto it = mSpawnedCopies.find(instance_id); + if (it == mSpawnedCopies.end()) + { + return; + } + for (LLPointer& p : it->second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + detachRootIfAttached(p.get()); // unwear before it dies (only the root is worn) + p->markDead(); // the root's markDead cascades to its children + } + } + mSpawnedCopies.erase(it); + mUnitsChangedSignal(); // rezzed state changed +} + +void LLLocalMeshMgr::despawnAll() +{ + for (auto& entry : mSpawnedCopies) + { + for (LLPointer& p : entry.second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + detachRootIfAttached(p.get()); // unwear before it dies + p->markDead(); // the root's markDead cascades to its children + } + } + } + mSpawnedCopies.clear(); + mUnitsChangedSignal(); // rezzed state changed +} + +std::string LLLocalMeshMgr::statusText(LLViewerObject* root) const +{ + if (!root) + { + return std::string(); + } + if (root->isAttachment()) + { + const S32 point_id = ATTACHMENT_ID_FROM_STATE(root->getAttachmentState()); + std::string point_name = llformat("%d", point_id); + if (isAgentAvatarValid()) + { + LLVOAvatar::attachment_map_t::const_iterator iter = + gAgentAvatarp->mAttachmentPoints.find(point_id); + if (iter != gAgentAvatarp->mAttachmentPoints.end() && iter->second) + { + point_name = iter->second->getName(); + } + } + LLSD args; + args["POINT"] = point_name; + return LLTrans::getString("LocalAssetAttached", args); + } + return LLTrans::getString("LocalAssetRezzed"); +} + +void LLLocalMeshMgr::setIncludeJointPositions(const LLUUID& tracking_id, bool include) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || unit->mIncludeJointPositions == include) + { + return; + } + unit->mIncludeJointPositions = include; + unit->forceReload(); // rebuild the skin with/without the joint-position overrides + mUnitsChangedSignal(); // persisted property changed -> let LLLocalAssetPaths re-save +} + +bool LLLocalMeshMgr::getIncludeJointPositions(const LLUUID& tracking_id) const +{ + const LLLocalMesh* unit = getUnit(tracking_id); + return unit && unit->mIncludeJointPositions; +} + +void LLLocalMeshMgr::applyPartGeometry(LLVOVolume* vol, const LLLocalMeshPart& part) +{ + // isSculpted()/isMesh() key off the PARAMS_SCULPT extra param (not the volume + // params alone). local_origin=false so nothing is sent to the sim for this + // client-only object. + LLSculptParams sculpt_params; + sculpt_params.setSculptTexture(part.mWorldID, LL_SCULPT_TYPE_MESH); + vol->setParameterEntry(LLNetworkData::PARAMS_SCULPT, sculpt_params, false); + + // Reference the part by its world id; the repository injection resolves it to + // the decoded geometry. + LLVolumeParams params; + params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + params.setSculptID(part.mWorldID, LL_SCULPT_TYPE_MESH); + vol->setVolume(params, LLVolumeLODGroup::NUM_LODS - 1); + + // Apply each face's imported material (M7.2): the diffuse map (a mesh-owned + // local bitmap), diffuse color and fullbright captured at ingest. Faces with no + // map fall back to the default texture. No sendTEUpdate() -- the object is + // client-only (isLocalOnly) and these setters only touch local render state. + // + // Use the PART's decoded face count, not vol->getVolume()'s. At initial spawn + // the viewer object's mesh volume can still report a placeholder face count -- + // the repository injection that resolves the sculpt id to our geometry hasn't + // realized on this object yet -- so getNumVolumeFaces() reads too few faces and + // only the first face(s) get textures/materials; the rest stay default until a + // reload/hot-swap (where the volume is already realized) re-runs this. That is + // why a fresh import "only materials one face/link until the source is + // refreshed". part.mNumFaces is the geometry we injected (== part.mVolume's face + // count), available synchronously, so setNumTEs() and the per-face apply below + // always cover every face -- and the object then carries the full TE count for + // later edits (e.g. Apply-to-Selected, which is bounded by getNumTEs()). + const S32 num_faces = part.mNumFaces; + bool any_render_mat = false; + for (S32 i = 0; i < num_faces && i < (S32)part.mFaceMaterials.size(); ++i) + { + if (part.mFaceMaterials[i].mRenderMaterialID.notNull()) + { + any_render_mat = true; + break; + } + } + if (num_faces > 0) + { + vol->setNumTEs((U8)num_faces); + + // Mark the render-material extra param IN USE *before* applying per-face + // materials. setRenderMaterialID() only persists an id into the param block + // when getRenderMaterialParams() returns it, and that returns null until the + // param is in use. Applied per-face beforehand, each call created a throwaway + // block and only the last face's id survived -- so the rebuild's + // updateTEMaterialTextures() then cleared every other face's material (a + // freshly imported glTF mesh showed its material on only one face). A + // client-only object never gets the server echo that would normally set this. + if (any_render_mat) + { + vol->setHasRenderMaterialParams(true); + } + + for (S32 i = 0; i < num_faces; ++i) + { + const LLLocalMeshFaceMaterial* fm = + (i < (S32)part.mFaceMaterials.size()) ? &part.mFaceMaterials[i] : nullptr; + const LLUUID tex = (fm && fm->mDiffuseID.notNull()) ? fm->mDiffuseID : IMG_DEFAULT; + vol->setTETexture((U8)i, tex); + if (fm) + { + vol->setTEColor((U8)i, fm->mDiffuseColor); + vol->setTEFullbright((U8)i, fm->mFullbright ? 1 : 0); + if (fm->mRenderMaterialID.notNull()) + { + // glTF PBR material (owns its own textures); update_server=false + // because the preview is client-only. + vol->setRenderMaterialID((S32)i, fm->mRenderMaterialID, false, true); + } + } + } + } + + vol->markForUpdate(); +} + +void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scene& scene, U32 load_state) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit) // removed while loading + { + return; + } + + const bool reloading = unit->isReloading(); + const bool parse_ok = (load_state < LLModelLoader::ERROR_PARSING) && !scene.empty(); + const bool assembled = parse_ok && unit->ingestScene(scene); + + if (!parse_ok) + { + LL_WARNS("LocalMesh") << "Parse failed (state " << load_state << ") for " << unit->getFilename() << LL_ENDL; + } + + if (reloading) + { + unit->finishReload(assembled); + if (assembled) + { + logUnit("Reloaded", unit); + // Refresh an in-world linkset: hot-swap the geometry in place (no despawn, + // so attachment/selection/transform survive). A changed prim count can't be + // swapped 1:1 -> fall back to a full re-spawn (which restores the attachment + // if the preview was worn). Not spawned -> just keep the rebuilt data. + if (getSpawnedRoot(tracking_id)) + { + if (!hotSwapInWorld(tracking_id)) + { + respawnInstancesInPlace(tracking_id); // prim count changed -> re-rez each copy + } + // The skin may have changed (a live edit, or a Joint Positions + // toggle), so recompute joint-position overrides on every affected + // avatar. rebuildAttachmentOverrides() clears the old offsets + // (resetting the skeleton) then re-adds from the current skin -- so a + // toggle-off truly reverts the skeleton instead of leaving the + // previous mesh's pelvis/joint offsets stuck on it. Each worn/animesh + // copy may sit on a different avatar, so rebuild each one (deduped); + // getAvatar() resolves the agent (attached) or the control avatar + // (animesh); a plain rezzed rigged copy has none, so it's skipped. + std::unordered_set avatars; + for (const SpawnedInstance& inst : getSpawnedInstances()) + { + if (inst.mTrackingID == tracking_id && inst.mRoot) + { + if (LLVOAvatar* av = inst.mRoot->getAvatar()) + { + avatars.insert(av); + } + } + } + for (LLVOAvatar* av : avatars) + { + av->rebuildAttachmentOverrides(); + } + } + } + return; + } + + // Initial load. + if (assembled) + { + logUnit("Loaded", unit); + // A mesh with no UVs can't be textured -- warn once here (not on reload, to + // avoid spam) so a white/untextured preview isn't a silent mystery. + if (!unit->hasUVs()) + { + LL_WARNS("LocalMesh") << "Mesh has no UV coordinates: " << unit->getFilename() + << " -- textures/materials cannot be previewed." << LL_ENDL; + LLSD args; + args["FNAME"] = unit->getFilename(); + LLNotificationsUtil::add("LocalMeshNoUVs", args); + } + if (unit->wantsSpawn()) + { + LLViewerObject* root = spawnInWorld(tracking_id); + // Deferred attach: Attach was pressed on this unit while it was still + // undecoded (see addAndAttach) -- wear it now that it's in-world. + if (root && unit->wantsAttach() >= 0) + { + attachPreviewToAvatar(root, unit->wantsAttach()); + } + } + } + else + { + unit->markFailed(); + delUnit(tracking_id); // drop the failed unit + } +} + +void LLLocalMeshMgr::doUpdates() +{ + // Stop/restart around the sweep so a long poll can't re-enter via the timer. + mTimer.stopTimer(); + for (LLLocalMesh* unit : mMeshList) + { + unit->pollForReload(); + } + if (!mMeshList.empty()) + { + mTimer.startTimer(); + } +} + +void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames) +{ + for (const std::string& filename : filenames) + { + if (filename.empty()) + { + continue; + } + const LLUUID tracking_id = addUnit(filename); + if (tracking_id.notNull()) + { + if (LLLocalMesh* unit = getUnit(tracking_id)) + { + unit->setSpawnWhenReady(true); + } + } + } +} + +void LLLocalMeshMgr::addAndAttach(const std::string& filename, S32 attach_point) +{ + if (filename.empty()) + { + return; + } + const LLUUID tracking_id = addUnit(filename); // dedups: existing unit if already loaded + LLLocalMesh* unit = tracking_id.notNull() ? getUnit(tracking_id) : nullptr; + if (!unit) + { + return; + } + if (unit->getValid()) + { + // Already decoded -- spawn and wear it now. + if (LLViewerObject* root = spawnInWorld(tracking_id)) + { + attachPreviewToAvatar(root, attach_point); + } + } + else + { + // Still loading (the common case for an undecoded row) -- onLoadResult will + // spawn and attach it once the parse finishes. + unit->setSpawnWhenReady(true); + unit->setAttachWhenReady(attach_point); + } +} + +void LLLocalMeshMgr::despawnUnit(const LLUUID& tracking_id) +{ + for (auto iter = mSpawnedCopies.begin(); iter != mSpawnedCopies.end(); ) + { + if (iter->second.mTrackingID == tracking_id) + { + for (LLPointer& p : iter->second.mPrims) + { + if (p.notNull() && !p->isDead()) + { + detachRootIfAttached(p.get()); // unwear before it dies + p->markDead(); // the root's markDead cascades to its children + } + } + iter = mSpawnedCopies.erase(iter); + } + else + { + ++iter; + } + } + mUnitsChangedSignal(); // rezzed state changed +} + +void LLLocalMeshMgr::feedScrollList(LLScrollListCtrl* ctrl) +{ + if (!ctrl) + { + return; + } + + const std::string icon_name = LLInventoryIcon::getIconName(LLAssetType::AT_OBJECT, LLInventoryType::IT_OBJECT); + + for (LLLocalMesh* unit : mMeshList) + { + const S32 count = getSpawnedCount(unit->getTrackingID()); + LLViewerObject* root = getSpawnedRoot(unit->getTrackingID()); + + 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"] = unit->getShortName(); + if (root) + { + element["columns"][1]["font"]["style"] = "BOLD"; // in-world (rezzed or worn) + } + + // Status column (the mesh tab adds it): the in-world copy count, or -- when + // there's exactly one copy -- that copy's state + attachment point. + std::string status; + if (count > 1) + { + LLSD args; + args["COUNT"] = count; + status = LLTrans::getString("LocalMeshInWorldCount", args); + } + else if (root) + { + status = statusText(root); + } + element["columns"][2]["column"] = "status"; + element["columns"][2]["type"] = "text"; + element["columns"][2]["value"] = status; + + LLSD data; + data["id"] = unit->getTrackingID(); + data["type"] = (S32)LLAssetType::AT_OBJECT; + element["value"] = data; + + ctrl->addElement(element); + } +} + +/*=======================================*/ +/* LLLocalMeshTimer: live-reload poll */ +/*=======================================*/ +static const F32 LOCAL_MESH_TIMER_HEARTBEAT = 3.0f; // seconds between file-change polls + +LLLocalMeshTimer::LLLocalMeshTimer() + : LLEventTimer(LOCAL_MESH_TIMER_HEARTBEAT) +{ +} + +void LLLocalMeshTimer::startTimer() { mEventTimer.start(); } +void LLLocalMeshTimer::stopTimer() { mEventTimer.stop(); } +bool LLLocalMeshTimer::isRunning() { return mEventTimer.getStarted(); } + +bool LLLocalMeshTimer::tick() +{ + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->doUpdates(); + } + return false; // keep ticking +} diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h new file mode 100644 index 0000000000..43a3fbff02 --- /dev/null +++ b/indra/newview/lllocalmesh.h @@ -0,0 +1,387 @@ +/** + * @file lllocalmesh.h + * @brief Local Mesh 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 Mesh is the mesh analog of Local Bitmap (lllocalbitmaps.h): it loads a +// Collada (.dae) or glTF (.gltf/.glb) file from disk via the same loaders the +// mesh upload path uses, decodes it to in-memory geometry + skin, and assigns +// it a client-only "world" UUID so the rest of the viewer can reference it like +// any other mesh asset -- without uploading to the asset server. +// +// Loading runs on the model-loader's worker thread (LLModelLoader::start), the +// same as the upload floater, because the glTF asset loader blocks on work it +// posts back to the main thread; decoding synchronously on the main thread +// would deadlock. The decoded result is assembled on a main-thread callback. + +#ifndef LL_LLLOCALMESH_H +#define LL_LLLOCALMESH_H + +#include "llmodelloader.h" // LLModelLoader, LLModelLoader::scene, JointMap, LLMeshSkinInfo +#include "lleventtimer.h" // LLEventTimer (live-reload polling) +#include "llpointer.h" +#include "llsingleton.h" +#include "lluuid.h" +#include "v3math.h" +#include "v4color.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class LLQuaternion; +class LLScrollListCtrl; +class LLViewerObject; +class LLViewerRegion; +class LLVOAvatar; +class LLVOVolume; +class LLVolume; + +// One uploadable sub-mesh of a local mesh file: a single LLModel's geometry, +// normalized to a unit box (as the upload path does), plus where it sits within +// the model. A file with more than 8 faces -- or multiple mesh nodes -- yields +// several parts, exactly the multi-prim linkset an upload of the same file would +// produce. A simple single-mesh file is just one part. +// A single face's material as imported from the model file, resolved to ids the +// viewer can render directly (no upload). For Blinn-Phong (.dae) the diffuse map is +// a mesh-owned local-bitmap world id; for glTF the whole material is a local-gltf +// world id applied as the face's render material. +struct LLLocalMeshFaceMaterial +{ + LLUUID mDiffuseID; // local-bitmap world id (Blinn-Phong), or null + LLColor4 mDiffuseColor = LLColor4::white; + bool mFullbright = false; + LLUUID mRenderMaterialID; // local-gltf world id (glTF) -> face render material, or null +}; + +struct LLLocalMeshPart +{ + std::string mName; // sub-mesh (model/instance) name, for build-floater display + LLUUID mWorldID; // unique mesh id objects/repository reference + LLPointer mVolume; // normalized geometry (<= 8 faces) + LLPointer mSkinInfo; // null if not rigged + LLVector3 mScale; // object scale (decomposed instance scale; rigged: authored size) + LLQuaternion mRotation; // object rotation (decomposed instance rotation; rigged: identity) + LLVector3 mOffset; // scene-space position of the part (static); zero for rigged + S32 mNumFaces = 0; + std::vector mFaceMaterials; // parallel to the volume's faces +}; + +// A single local mesh file and its decoded, in-memory representation (one or more +// parts spawned together as a linkset). +class LLLocalMesh +{ +public: + LLLocalMesh(std::string filename, bool include_joints = false); + ~LLLocalMesh(); + + std::string getFilename() const { return mFilename; } + std::string getShortName() const { return mShortName; } + LLUUID getTrackingID() const { return mTrackingID; } + + bool getValid() const { return mState == ST_LOADED; } + bool isLoading() const { return mState == ST_LOADING; } + bool isFailed() const { return mState == ST_FAILED; } + + // Decoded parts -- consumed by the spawn path and repository injection. + const std::vector& getParts() const { return mParts; } + bool isRigged() const; // true if any part is rigged + bool hasUVs() const { return mHasUVs; } // false if the source mesh shipped without texcoords + + // Stats (for UI + logging). + S32 getNumParts() const { return (S32)mParts.size(); } + S32 getNumFaces() const { return mNumFaces; } + S32 getNumVertices() const { return mNumVertices; } + S32 getNumTriangles() const { return mNumTriangles; } + S32 getNumJoints() const { return mNumJoints; } + + // Spawn the preview in-world automatically once loading completes. + void setSpawnWhenReady(bool b) { mSpawnWhenReady = b; } + bool wantsSpawn() const { return mSpawnWhenReady; } + + // Deferred attach: wear the preview at this attachment point once loading + // completes (-1 = don't). Set by LLLocalMeshMgr::addAndAttach when the user + // hits Attach on a not-yet-decoded unit. Requires wantsSpawn(). + void setAttachWhenReady(S32 attach_point) { mAttachWhenReady = attach_point; } + S32 wantsAttach() const { return mAttachWhenReady; } + +private: + friend class LLLocalMeshMgr; // orchestrates loading/reload and spawning + + enum EFormat { FMT_NONE, FMT_DAE, FMT_GLTF }; + enum EState { ST_LOADING, ST_LOADED, ST_FAILED }; + + void startLoad(); + + // Assemble decoded geometry into parts and commit them to this unit; returns + // false (leaving the previous parts untouched) if the scene yielded nothing, + // so a failed live-reload keeps showing the last good mesh. Each call mints + // fresh part world ids so a reload serves new geometry cleanly. + bool ingestScene(LLModelLoader::scene& scene); + // Register a model-referenced image as a mesh-owned local bitmap (deduped by + // filename) and return its world id. Records every mesh-owned unit it references + // into `owned` (the set this parse keeps) so ingestScene can release the rest. + LLUUID registerOwnedBitmap(const std::string& filename, std::vector& owned); + // Register this glTF file's materials (mesh-owned, deduped) and map each by its + // face-binding name to its world id, for applying them per face in ingestScene. + // Records the file's mesh-owned material units into `owned` (kept this parse). + void importGLTFMaterials(std::map& out_by_name, std::vector& owned); + void markFailed() { mState = ST_FAILED; } + + // Live reload (M3): poll the source file's mtime and, on a change, kick an + // async re-parse. The geometry swap happens back in the load callback. + bool pollForReload(); // true if a reload was started this poll + bool forceReload(); // re-parse now (e.g. after a build-option change) + void finishReload(bool ok); // clear in-flight state after the parse returns + bool isReloading() const { return mReloading; } + + std::string mFilename; + std::string mShortName; + LLUUID mTrackingID; // stable, identifies this unit in UI + EFormat mFormat; + EState mState; + bool mSpawnWhenReady; + S32 mAttachWhenReady = -1; // attach point to wear at once loaded (-1 = none) + bool mIncludeJointPositions = false; // bake joint-position overrides into the skin + + std::filesystem::file_time_type mLastModified; // for live reload (M3) + + std::vector mParts; // one per LLModel; spawned together as a linkset + + // Local-bitmap tracking ids imported for this unit's materials (mesh-owned); + // released from LLLocalBitmapMgr when this unit is deleted. + std::vector mOwnedBitmaps; + // Local-gltf-material tracking ids imported for this unit (mesh-owned); released + // from LLLocalGLTFMaterialMgr when this unit is deleted. + std::vector mOwnedMaterials; + + S32 mNumFaces; // totals across all parts + S32 mNumVertices; + S32 mNumTriangles; + S32 mNumJoints; + bool mHasUVs = true; // false if the source mesh had no UV coordinates (untexturable) + + // Live-reload bookkeeping. + bool mReloading; // an async re-parse is in flight + std::filesystem::file_time_type mPendingModified; // mtime of the in-flight reload + std::filesystem::file_time_type mFailedModified; // mtime that last failed to parse +}; + +// Periodic tick that lets loaded units watch their source files for changes. +class LLLocalMeshTimer : public LLEventTimer +{ +public: + LLLocalMeshTimer(); + void startTimer(); + void stopTimer(); + bool isRunning(); + bool tick() override; +}; + +// Owns all loaded local meshes and resolves between tracking IDs, world IDs and +// filenames. +class LLLocalMeshMgr : public LLSingleton +{ + LLSINGLETON(LLLocalMeshMgr); + ~LLLocalMeshMgr(); + +public: + LLUUID addUnit(const std::string& filename, bool include_joints = false); + bool addUnit(const std::vector& filenames); + void delUnit(LLUUID tracking_id); + + LLUUID getUnitID(const std::string& filename) const; + std::string getFilename(const LLUUID& tracking_id) const; + // Every currently-loaded source file path (for cross-session persistence). + std::vector getFilenames() const; + + LLLocalMesh* getUnit(const LLUUID& tracking_id) const; + + // True if a mesh-owned import (tracking id) is still referenced by some loaded + // mesh other than `exclude` -- so a reload that drops it doesn't delUnit a unit a + // sibling mesh (sharing the same texture/material file) still needs. + bool isImportOwnedByOther(const LLUUID& tracking_id, const LLLocalMesh* exclude) const; + + // Per-unit toggle: include the mesh's joint-position overrides (alt-inverse-bind + // + pelvis offset) when building its skin, for fitted bodies that need them. + // Changing it re-parses the unit (and re-spawns it in place if it is in-world). + void setIncludeJointPositions(const LLUUID& tracking_id, bool include); + bool getIncludeJointPositions(const LLUUID& tracking_id) const; + + // Mesh repository injection: resolve a part's world id to its decoded data. + bool isLocal(const LLUUID& world_id) const; + LLVolume* getVolumeForWorldID(const LLUUID& world_id) const; + const LLMeshSkinInfo* getSkinInfoForWorldID(const LLUUID& world_id) const; + + // Despawn in-world preview copies but KEEP the loaded unit so it can be re-spawned + // (a later file save won't auto-respawn a despawned unit -- see onLoadResult). + // despawnPreviewObject derezzes just the copy that owns `obj`, letting the standard + // in-world Delete key/menu "derez" a single client-only copy (which the sim delete + // path can't touch) without dropping the file from the list. despawn() derezzes + // ALL of a unit's copies ("Derez All"). To remove the file from the list entirely, + // use delUnit(). + void despawnPreviewObject(LLViewerObject* obj); + void despawn(const LLUUID& tracking_id); + void despawnAll(); // derez every copy of every unit (Spawned tab "Derez All") + + // Attach/detach a preview linkset to the agent avatar, driven by the normal + // "Attach"/"Detach" object menus (the viewer's sim attach/detach can't act on + // a client-only object). Each acts on the preview linkset that owns `obj` (root + // or child). attach_point is the avatar attachment-point id the user picked + // (the key into LLVOAvatar::mAttachmentPoints; render order is sorted by it); + // 0 means the default point (chest). isRiggedPreview is true when `obj` is a + // local preview whose unit is rigged; isPreviewAttached reflects whether that + // linkset is currently worn. + void attachPreviewToAvatar(LLViewerObject* obj, S32 attach_point = 0, bool replace = false); + void detachPreviewFromAvatar(LLViewerObject* obj); + bool isRiggedPreview(const LLViewerObject* obj) const; + bool isPreviewAttached(const LLViewerObject* obj) const; + + // Rez a NEW client-only copy ("instance") of the unit's parts in front of the + // agent. A unit can be rezzed any number of times; each copy is independent with + // its own instance id. Returns the new copy's linkset root. + LLViewerObject* spawnInWorld(const LLUUID& tracking_id); + // Convenience: load each file and spawn it in-world once it finishes loading. + void addAndSpawn(const std::vector& filenames); + // Load a file (if needed) and, once decoded, spawn it and wear it at the given + // attachment point -- so Attach works on a not-yet-decoded unit. + void addAndAttach(const std::string& filename, S32 attach_point); + + // The root of (any) one rezzed copy of a unit, or null if it has none -- handy for + // "is this unit in-world?" checks. Use getSpawnedInstances() to act per copy. + LLViewerObject* getSpawnedRoot(const LLUUID& tracking_id) const; + // For the build floater: resolve the preview that owns `obj` (root or child + // prim) to a display name -- the selected sub-mesh's own name when it has one, + // else the file's short name -- plus the source file path. False if not a preview. + bool getPreviewDisplay(const LLViewerObject* obj, std::string& name_out, std::string& path_out) const; + // Number of live rezzed copies of a unit. + S32 getSpawnedCount(const LLUUID& tracking_id) const; + + // A single rezzed copy of a unit; mRoot is its linkset root. + struct SpawnedInstance + { + LLUUID mInstanceID; + LLUUID mTrackingID; + LLViewerObject* mRoot; + }; + // Every live rezzed copy across all units, in rez order (for the Spawned tab). + std::vector getSpawnedInstances() const; + // The linkset root of a specific rezzed copy, or null. + LLViewerObject* getInstanceRoot(const LLUUID& instance_id) const; + // Derez a single rezzed copy, leaving the loaded unit and the unit's other copies. + void despawnInstance(const LLUUID& instance_id); + // Localized in-world status of a copy's root ("Rezzed" / "Attached: "), + // shared by the mesh list and the Spawned tab. Empty for a null root. + std::string statusText(LLViewerObject* root) const; + + void feedScrollList(LLScrollListCtrl* ctrl); + + // Fired when the unit list or any unit's in-world (rezzed) state changes, so the + // Local Assets floater refreshes reactively no matter who made the change + // (in-world Delete/derez, login reload, the floater itself, ...). + boost::signals2::connection setUnitsChangedCallback(const std::function& cb); + + // Called by the load callback when an async (re)parse finishes (main thread). + void onLoadResult(const LLUUID& tracking_id, LLModelLoader::scene& scene, U32 load_state); + + // Timer tick: poll every loaded unit's source file for changes. + void doUpdates(); + + // A dedicated, never-rendered UI avatar that the model loaders resolve joints + // against (and dump joint-position overrides onto) so loading a rigged mesh + // does not deform the real agent avatar. Created lazily on first load. + // Left at its default REST pose by default: the FBX loader rebuilds missing inverse + // binds as inverse(joint world matrix), so the skeleton must sit at the canonical bind + // pose. Pass run_stand_anim=true only if a caller actually renders this avatar. + LLVOAvatar* getPreviewAvatar(bool run_stand_anim = false); + + // Destroy all client-only preview objects and the preview avatar. Wired into + // LLViewerObjectList::killAllObjects() so they are released while the object + // list is still valid, ahead of singleton teardown. Idempotent. + void cleanup(); + + // Release the preview objects (and preview avatar) hosted by a region that is + // being torn down. Wired into LLViewerObjectList::killObjects(region) so they + // don't dangle past their host region. The loaded units stay; a later spawn + // re-creates them in the current region. + void despawnObjectsInRegion(LLViewerRegion* regionp); + +private: + LLUUID addUnitInternal(const std::string& filename, bool include_joints = false); + void despawnUnit(const LLUUID& tracking_id); + + // Reload every spawned copy of a unit in place by pointing each existing prim at + // the freshly decoded geometry (new sculpt id) instead of despawning -- so + // attachment, selection and transform are preserved and there's no flicker. + // Returns false if the prim count changed (caller falls back to a re-spawn). + bool hotSwapInWorld(const LLUUID& tracking_id); + // Re-rez every existing copy of a unit at its current transform/attachment, used + // when a reload changes the prim count so hot-swap can't swap 1:1. + void respawnInstancesInPlace(const LLUUID& tracking_id); + // Build one copy's linkset for (tracking_id, instance_id) rooted at `base` with + // `root_rot`, optionally worn at attach_point. Returns the root. + LLViewerObject* spawnLinkset(const LLUUID& tracking_id, const LLUUID& instance_id, + const LLVector3& base, const LLQuaternion& root_rot, + bool attach, S32 attach_point); + // The instance id of the copy that owns `obj` (root or child), or null. + LLUUID instanceForObject(const LLViewerObject* obj) const; + + // Find a decoded part by its world id (across all loaded units). + const LLLocalMeshPart* findPart(const LLUUID& world_id) const; + // The spawned linkset root for the unit that owns `obj` (root or child), or null. + LLViewerObject* findRootForObject(const LLViewerObject* obj) const; + // Detach a spawned root from the agent avatar if it is currently worn (used by + // despawn/cleanup so an attached preview doesn't dangle on the avatar). + void detachRootIfAttached(LLViewerObject* root); + // Point a freshly created object at a part's geometry/skin (sculpt id + + // setVolume + default textures). Does not set the object's transform. + void applyPartGeometry(LLVOVolume* vol, const LLLocalMeshPart& part); + + typedef std::list::iterator local_list_iter; + typedef std::list::const_iterator local_list_citer; + std::list mMeshList; + + // A rezzed copy ("instance") of a unit: its whole linkset, with mPrims[0] the + // root. Keyed by instance id in mSpawnedCopies, so per-copy lookup/erase is O(1) + // and a copy's prims are grouped together -- per-unit/per-copy work then scales + // with the copy count, not the total prim count across every copy. + struct SpawnedCopy + { + LLUUID mTrackingID; // the unit this copy came from + U32 mSeq = 0; // rez order, for stable listing + std::vector > mPrims; // mPrims[0] is the linkset root + }; + std::unordered_map mSpawnedCopies; // instance id -> copy + U32 mNextSpawnSeq = 0; // monotonic rez counter feeding SpawnedCopy::mSeq + + boost::signals2::signal mUnitsChangedSignal; // add/remove/spawn/despawn + + LLLocalMeshTimer mTimer; // drives live-reload polling + LLPointer mPreviewAvatar; // skeleton for joint resolution (never the agent) +}; + +#endif // LL_LLLOCALMESH_H diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index e301da4760..14ca3d947c 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -46,6 +46,7 @@ #include "llsdserialize.h" #include "llthread.h" #include "llfilesystem.h" +#include "lllocalmesh.h" #include "llviewercontrol.h" #include "llviewerinventory.h" #include "llviewermenufile.h" @@ -4214,6 +4215,11 @@ void LLMeshRepository::init() { mMeshMutex = new LLMutex(); + // Create the local mesh registry up front so the isLocal()/getUnit*() + // short-circuits below have an instance to consult. The hot-path checks + // stay guarded by instanceExists() for shutdown safety. + LLLocalMeshMgr::getInstance(); + // initSystem is static; call it directly. getInstance() returns null here (s_isInitialized is false) // and dispatching a static method through a null pointer is UB. LLConvexDecomposition::initSystem(); @@ -4243,6 +4249,14 @@ void LLMeshRepository::shutdown() llassert(mThread != NULL); llassert(mThread->mSignal != NULL); + // Tear down the local mesh registry we created in init(). Its preview objects + // and avatar were already released by LLViewerObjectList::killAllObjects(); + // this frees the decoded units and the singleton itself. + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::deleteSingleton(); + } + metrics_teleport_started_signal.disconnect(); for (U32 i = 0; i < mUploads.size(); ++i) @@ -4381,6 +4395,37 @@ S32 LLMeshRepository::loadMesh(LLVOVolume* vobj, const LLVolumeParams& mesh_para return new_lod; } + // Local mesh: serve the decoded geometry directly from the registry instead + // of issuing an asset fetch. The same geometry is served for every LOD. + // Local mesh: decide locality by registry membership, not by whether a decoded + // volume happens to exist right now. A local-only world id must never fall through + // to the simulator-backed fetch below -- not even during the brief window of a + // hot-reload when its volume is momentarily unavailable (it has no server asset). + if (LLLocalMeshMgr::instanceExists() && + LLLocalMeshMgr::getInstance()->isLocal(mesh_params.getSculptID())) + { + LLVolume* local_volume = LLLocalMeshMgr::getInstance()->getVolumeForWorldID(mesh_params.getSculptID()); + if (local_volume) + { + LLVolume* sys_volume = LLPrimitive::getVolumeManager()->refVolume(mesh_params, new_lod); + if (sys_volume) + { + // Always recopy: a live reload rebuilds the local volume in place while + // the cached system volume keeps the same world id, so an + // isMeshAssetLoaded() gate here would pin the stale geometry. + sys_volume->copyVolumeFaces(local_volume); + sys_volume->setMeshAssetLoaded(true); + LLPrimitive::getVolumeManager()->unrefVolume(sys_volume); + } + if (vobj) + { + vobj->notifyMeshLoaded(); + } + } + // Served above, or still decoding mid-reload -- either way, don't remote-fetch. + return new_lod; + } + { LLMutexLock lock(mMeshMutex); //add volume to list of loading meshes @@ -4864,12 +4909,28 @@ void LLMeshRepository::notifyMeshUnavailable(const LLVolumeParams& mesh_params, S32 LLMeshRepository::getActualMeshLOD(const LLVolumeParams& mesh_params, S32 lod) { + // Local mesh has no header/LOD list; the same geometry is valid for every LOD. + if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_params.getSculptID())) + { + return llclamp(lod, 0, LLVolumeLODGroup::NUM_LODS - 1); + } return mThread->getActualMeshLOD(mesh_params, lod); } const LLMeshSkinInfo* LLMeshRepository::getSkinInfo(const LLUUID& mesh_id, LLVOVolume* requesting_obj) { LL_PROFILE_ZONE_SCOPED_CATEGORY_AVATAR; + + // Local mesh: serve the decoded skin (or null if static) and never fetch. + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + if (mgr->isLocal(mesh_id)) + { + return mgr->getSkinInfoForWorldID(mesh_id); + } + } + if (mesh_id.notNull()) { skin_map::iterator iter = mSkinMap.find(mesh_id); @@ -5008,6 +5069,15 @@ bool LLMeshRepository::hasSkinInfo(const LLUUID& mesh_id) return false; } + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + if (mgr->isLocal(mesh_id)) + { + return mgr->getSkinInfoForWorldID(mesh_id) != nullptr; + } + } + if (mThread->hasSkinInfoInHeader(mesh_id)) { return true; @@ -5029,6 +5099,11 @@ bool LLMeshRepository::hasHeader(const LLUUID& mesh_id) const return false; } + if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) + { + return true; + } + return mThread->hasHeader(mesh_id); } diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 428e150a42..cabdbaef55 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -2895,6 +2895,12 @@ void LLModelPreview::genBuffers(S32 lod, bool include_skin_weights) mat_normal.loadu(glm::value_ptr(m)); } + // O(1) per-vertex weight lookup for the skin-weight buffer fill below; + // without it the per-vertex getJointInfluences() scan is O(V^2) and stalls + // the preview on dense rigged meshes. Built once per model (empty/cheap + // when unskinned). + LLModel::JointWeightCache weight_cache(*mdl); + S32 num_faces = mdl->getNumVolumeFaces(); for (S32 i = 0; i < num_faces; ++i) { @@ -2994,7 +3000,7 @@ void LLModelPreview::genBuffers(S32 lod, bool include_skin_weights) //find closest weight to vf.mVertices[i].mPosition LLVector3 pos(vf.mPositions[i].getF32ptr()); - const LLModel::weight_list& weight_list = mdl->getJointInfluences(pos); + const LLModel::weight_list& weight_list = weight_cache.influences(pos); llassert(weight_list.size()>0 && weight_list.size() <= 4); // LLModel::loadModel() should guarantee this LLVector4 w(0, 0, 0, 0); diff --git a/indra/newview/llpanelcontents.cpp b/indra/newview/llpanelcontents.cpp index 500cd5feb5..8582305039 100644 --- a/indra/newview/llpanelcontents.cpp +++ b/indra/newview/llpanelcontents.cpp @@ -132,6 +132,23 @@ void LLPanelContents::getState(LLViewerObject *objectp ) return; } + if (objectp->isLocalOnly()) + { + // Client-only local mesh preview: it has no server-side task inventory, + // so scripts/contents/permissions don't apply. + getChildView("button new script")->setEnabled(false); + getChildView("button permissions")->setEnabled(false); + if (mFilterEditor) + { + mFilterEditor->setEnabled(false); + } + if (mPanelInventoryObject) + { + mPanelInventoryObject->setEnabled(false); + } + return; + } + LLUUID group_id; // used for SL-23488 LLSelectMgr::getInstance()->selectGetGroup(group_id); // sets group_id as a side effect SL-23488 @@ -173,6 +190,12 @@ void LLPanelContents::getState(LLViewerObject *objectp ) getChildView("button permissions")->setEnabled(!objectp->isPermanentEnforced()); mPanelInventoryObject->setEnabled(!objectp->isPermanentEnforced()); + if (mFilterEditor) + { + // Restore the filter the local-only branch above disables, so it isn't + // left stuck disabled after selecting a normal object next. + mFilterEditor->setEnabled(true); + } } void LLPanelContents::onFilterEdit() diff --git a/indra/newview/llpanelface.cpp b/indra/newview/llpanelface.cpp index 954deede34..05d6b79068 100644 --- a/indra/newview/llpanelface.cpp +++ b/indra/newview/llpanelface.cpp @@ -136,6 +136,22 @@ LLGLTFMaterial::TextureInfo LLPanelFace::getPBRTextureInfo() return LLGLTFMaterial::GLTF_TEXTURE_INFO_COUNT; } +// Apply a GLTF material override to a face. Client-only local mesh previews get +// no sim override echo, so apply the override directly (setTEGLTFMaterialOverride +// re-renders); real objects queue a ModifyMaterialParams sim update as usual. +static void apply_gltf_override(LLViewerObject* object, S32 te, const LLGLTFMaterial& new_override) +{ + if (object && object->isLocalOnly()) + { + LLPointer ov = new LLGLTFMaterial(new_override); + object->setTEGLTFMaterialOverride((U8)te, ov); + } + else + { + LLGLTFMaterialList::queueModify(object, te, &new_override); + } +} + void LLPanelFace::updateSelectedGLTFMaterials(std::function func) { struct LLSelectedTEGLTFMaterialFunctor : public LLSelectedTEFunctor @@ -151,7 +167,7 @@ void LLPanelFace::updateSelectedGLTFMaterials(std::functiongetGLTFMaterialOverride(); } mFunc(&new_override); - LLGLTFMaterialList::queueModify(object, face, &new_override); + apply_gltf_override(object, face, new_override); return true; } @@ -181,7 +197,7 @@ void LLPanelFace::updateSelectedGLTFMaterialsWithScale(std::functiongetScale().mV[s_axis], object->getScale().mV[t_axis]); - LLGLTFMaterialList::queueModify(object, face, &new_override); + apply_gltf_override(object, face, new_override); return true; } @@ -891,7 +907,7 @@ struct LLPanelFaceSetAlignedTEFunctor : public LLSelectedTEFunctor if (any_changed) { - LLGLTFMaterialList::queueModify(object, te, &new_override); + apply_gltf_override(object, te, new_override); } } else @@ -910,7 +926,7 @@ struct LLPanelFaceSetAlignedTEFunctor : public LLSelectedTEFunctor transform.mScale.set(gltf_scale.mV[0], gltf_scale.mV[1]); transform.mRotation = gltf_rot; - LLGLTFMaterialList::queueModify(object, te, &new_override); + apply_gltf_override(object, te, new_override); } } } diff --git a/indra/newview/llpanelobject.cpp b/indra/newview/llpanelobject.cpp index bcdc62667b..127e643b3b 100644 --- a/indra/newview/llpanelobject.cpp +++ b/indra/newview/llpanelobject.cpp @@ -1296,6 +1296,15 @@ void LLPanelObject::getState( ) mObject = objectp; mRootObject = root_objectp; + + // Client-only local mesh previews have no sim physics: disable the sim-only + // object flags for them (their sends are already short-circuited). + if (objectp->isLocalOnly()) + { + if (mCheckPhysics) { mCheckPhysics->setEnabled(false); } + if (mCheckTemporary) { mCheckTemporary->setEnabled(false); } + if (mCheckPhantom) { mCheckPhantom->setEnabled(false); } + } } // static diff --git a/indra/newview/llpanelpermissions.cpp b/indra/newview/llpanelpermissions.cpp index 5909cfd355..3e58f650d7 100644 --- a/indra/newview/llpanelpermissions.cpp +++ b/indra/newview/llpanelpermissions.cpp @@ -1008,6 +1008,30 @@ void LLPanelPermissions::refresh() getChildView("label click action")->setEnabled(is_perm_modify && is_nonpermanent_enforced && all_volume); getChildView("clickaction")->setEnabled(is_perm_modify && is_nonpermanent_enforced && all_volume); + + // Client-only local mesh previews have no server-side identity: none of the + // sim-backed properties above apply, and edits would be silently dropped + // (sendListToRegions is short-circuited for them). Keep name/desc visible + // (read-only) but disable every editing/permission/sale/group/click control. + if (objectp->isLocalOnly()) + { + static const std::string sim_only_ctrls[] = { + "Object Name", "Object Description", + "button set group", "button deed", "checkbox share with group", + "checkbox allow everyone move", "checkbox allow everyone copy", + "checkbox next owner can modify", "checkbox next owner can copy", + "checkbox next owner can transfer", + "checkbox for sale", "sale type", "Edit Cost", + "search_check", "clickaction", + }; + for (const std::string& ctrl_name : sim_only_ctrls) + { + if (LLView* v = findChildView(ctrl_name, true)) + { + v->setEnabled(false); + } + } + } } // Shorten name if it doesn't fit into max_pixels of two lines diff --git a/indra/newview/llpanelvolume.cpp b/indra/newview/llpanelvolume.cpp index d45de6e138..ddc7f2ef85 100644 --- a/indra/newview/llpanelvolume.cpp +++ b/indra/newview/llpanelvolume.cpp @@ -708,6 +708,18 @@ void LLPanelVolume::getState( ) mBtnCopyLight->setEnabled(editable&& single_volume && volobjp); mBtnPasteLight->setEnabled(editable&& single_volume && volobjp && mClipboardParams.has("light")); mBtnPipetteLight->setEnabled(editable&& single_volume && volobjp); + + // Client-only local mesh previews have no sim physics: disable the physics + // shape/material controls for them (their sends are already short-circuited). + if (objectp->isLocalOnly()) + { + if (mComboPhysicsShapeType) { mComboPhysicsShapeType->setEnabled(false); } + if (mSpinPhysicsGravity) { mSpinPhysicsGravity->setEnabled(false); } + if (mSpinPhysicsFriction) { mSpinPhysicsFriction->setEnabled(false); } + if (mSpinPhysicsDensity) { mSpinPhysicsDensity->setEnabled(false); } + if (mSpinPhysicsRestitution) { mSpinPhysicsRestitution->setEnabled(false); } + if (mComboMaterial) { mComboMaterial->setEnabled(false); } + } } // static diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 52bda378d5..402b6c9202 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -101,8 +101,120 @@ // [/RLVa:KB] #include "llglheaders.h" #include "llinventoryobserver.h" +#include "lllocalmesh.h" LLViewerObject* getSelectedParentObject(LLViewerObject *object) ; + +// Local mesh preview objects are client-only (no sim object), so all selection +// and edit network traffic must be suppressed for them. +static bool isLocalPreviewObject(LLViewerObject* obj) +{ + // Client-only local mesh preview: an O(1) object flag, no manager lookup. + return obj && obj->isLocalOnly(); +} + +// True only if the selection is non-empty and consists entirely of client-only +// local mesh previews (so the whole server send can be skipped). A selection +// containing any real object returns false and is sent normally. +static bool selectionAllLocalPreview(LLObjectSelectionHandle selection) +{ + if (selection.isNull() || selection->getNumNodes() == 0) + { + return false; + } + for (LLObjectSelection::iterator iter = selection->begin(); iter != selection->end(); ++iter) + { + LLViewerObject* obj = (*iter)->getObject(); + if (obj && !isLocalPreviewObject(obj)) + { + return false; + } + } + return true; +} + +// Populate a select node for a client-only preview the way the sim would via +// processObjectProperties -- no such reply will ever arrive -- so the build +// tools treat it as a valid, fully agent-owned, editable object. +static void synthesizeLocalPreviewNode(LLSelectNode* nodep, LLViewerObject* objectp) +{ + if (!nodep || !isLocalPreviewObject(objectp)) + { + return; + } + nodep->mValid = true; + // Show the actual sub-mesh name/source instead of a generic placeholder. + nodep->mName = "(local mesh preview)"; + nodep->mDescription.clear(); + { + std::string mesh_name, mesh_path; + if (LLLocalMeshMgr::getInstance()->getPreviewDisplay(objectp, mesh_name, mesh_path)) + { + if (!mesh_name.empty()) + { + nodep->mName = mesh_name; + } + nodep->mDescription = mesh_path; + } + } + nodep->mPermissions->init(gAgent.getID(), gAgent.getID(), LLUUID::null, LLUUID::null); + const U32 full_perm = PERM_MODIFY | PERM_COPY | PERM_MOVE | PERM_TRANSFER; + nodep->mPermissions->initMasks(full_perm, full_perm, PERM_NONE, PERM_NONE, full_perm); + + // Snapshot the current face textures the way processObjectProperties would, so + // texture/material-picker revert (cancel) has a baseline to restore to. + uuid_vec_t texture_ids; + uuid_vec_t material_ids; + gltf_materials_vec_t override_materials; + const U8 num_tes = objectp->getNumTEs(); + texture_ids.reserve(num_tes); + material_ids.reserve(num_tes); + override_materials.reserve(num_tes); + for (U8 te = 0; te < num_tes; ++te) + { + const LLTextureEntry* tep = objectp->getTE(te); + texture_ids.push_back(tep ? tep->getID() : LLUUID::null); + material_ids.push_back(objectp->getRenderMaterialID(te)); + const LLGLTFMaterial* over = tep ? tep->getGLTFMaterialOverride() : nullptr; + override_materials.emplace_back(over ? new LLGLTFMaterial(*over) : nullptr); + } + nodep->saveTextures(texture_ids); + // Snapshot GLTF render-material ids + overrides too, so selectionRevertGLTFMaterials() + // can restore them when a material picker is cancelled on a local preview. + nodep->saveGLTFMaterials(material_ids, override_materials); +} + +// Delete a selection made up entirely of client-only previews. The sim delete +// path (DeRezObject/ObjectDelete) can't touch them, so despawn them locally. +static void deleteLocalPreviewSelection() +{ + if (!LLLocalMeshMgr::instanceExists()) + { + return; + } + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + LLObjectSelectionHandle selection = LLSelectMgr::getInstance()->getSelection(); + + // Snapshot the objects first; deleting despawns them and clears the selection. + std::vector > objects; + for (LLObjectSelection::iterator iter = selection->begin(); iter != selection->end(); ++iter) + { + if (LLViewerObject* obj = (*iter)->getObject()) + { + objects.push_back(obj); + } + } + + LLSelectMgr::getInstance()->deselectAll(); + + for (LLPointer& obj : objects) + { + // Derez the linkset (the in-world Delete key takes a local preview out of the + // world but keeps the loaded file in the Local Assets list). The first part of + // a linkset derezzes it; later parts no-op. Use the floater's Delete to unload. + mgr->despawnPreviewObject(obj.get()); + } +} // // Consts // @@ -490,15 +602,18 @@ LLObjectSelectionHandle LLSelectMgr::selectObjectOnly(LLViewerObject* object, S3 object->resetRot(); // Always send to simulator, so you get a copy of the - // permissions structure back. - gMessageSystem->newMessageFast(_PREHASH_ObjectSelect); - gMessageSystem->nextBlockFast(_PREHASH_AgentData); - gMessageSystem->addUUIDFast(_PREHASH_AgentID, gAgent.getID() ); - gMessageSystem->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); - gMessageSystem->nextBlockFast(_PREHASH_ObjectData); - gMessageSystem->addU32Fast(_PREHASH_ObjectLocalID, object->getLocalID() ); - LLViewerRegion* regionp = object->getRegion(); - gMessageSystem->sendReliable( regionp->getHost()); + // permissions structure back. (Skipped for client-only local previews.) + if (!isLocalPreviewObject(object)) + { + gMessageSystem->newMessageFast(_PREHASH_ObjectSelect); + gMessageSystem->nextBlockFast(_PREHASH_AgentData); + gMessageSystem->addUUIDFast(_PREHASH_AgentID, gAgent.getID() ); + gMessageSystem->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); + gMessageSystem->nextBlockFast(_PREHASH_ObjectData); + gMessageSystem->addU32Fast(_PREHASH_ObjectLocalID, object->getLocalID() ); + LLViewerRegion* regionp = object->getRegion(); + gMessageSystem->sendReliable( regionp->getHost()); + } updatePointAt(); updateSelectionCenter(); @@ -914,7 +1029,9 @@ void LLSelectMgr::deselectObjectAndFamily(LLViewerObject* object, bool send_to_s object->addThisAndAllChildren(objects); remove(objects); - if (!send_to_sim) return; + // Local previews carry fake local ids and can't be mixed with real objects in a + // selection, so never tell the sim we deselected one. + if (!send_to_sim || isLocalPreviewObject(object)) return; //----------------------------------------------------------- // Inform simulator of deselection @@ -972,7 +1089,7 @@ void LLSelectMgr::deselectObjectOnly(LLViewerObject* object, bool send_to_sim) object->setAngularVelocity( 0,0,0 ); object->setVelocity( 0,0,0 ); - if (send_to_sim) + if (send_to_sim && !isLocalPreviewObject(object)) { LLViewerRegion* region = object->getRegion(); gMessageSystem->newMessageFast(_PREHASH_ObjectDeselect); @@ -1013,6 +1130,7 @@ void LLSelectMgr::addAsFamily(std::vector& objects, bool add_to if (!objectp->isSelected()) { LLSelectNode *nodep = new LLSelectNode(objectp, true); + synthesizeLocalPreviewNode(nodep, objectp); if (add_to_end) { mSelectedObjects->addNodeAtEnd(nodep); @@ -1061,6 +1179,7 @@ void LLSelectMgr::addAsIndividual(LLViewerObject *objectp, S32 face, bool undoab nodep = new LLSelectNode(objectp, true); mSelectedObjects->addNode(nodep); llassert_always(nodep->getObject()); + synthesizeLocalPreviewNode(nodep, objectp); } else { @@ -1149,6 +1268,9 @@ LLObjectSelectionHandle LLSelectMgr::setHoverObject(LLViewerObject *objectp, S32 continue; } LLSelectNode* nodep = new LLSelectNode(cur_objectp, false); + // Local previews never get an ObjectProperties reply; populate the node + // locally so a preview reached via hover/box-select is still valid/editable. + synthesizeLocalPreviewNode(nodep, cur_objectp); nodep->selectTE(face, true); mHoverObjects->addNodeAtEnd(nodep); } @@ -1310,6 +1432,7 @@ LLObjectSelectionHandle LLSelectMgr::selectHighlightedObjects() } LLSelectNode* new_nodep = new LLSelectNode(*nodep); + synthesizeLocalPreviewNode(new_nodep, objectp); mSelectedObjects->addNode(new_nodep); // flag this object as selected @@ -4181,6 +4304,14 @@ void LLSelectMgr::selectDelete() } // [/RLVa:KB] + // Client-only local mesh previews have no sim counterpart; delete them + // locally instead of sending a DeRez the sim would ignore. + if (selectionAllLocalPreview(mSelectedObjects)) + { + deleteLocalPreviewSelection(); + return; + } + S32 deleteable_count = 0; bool locked_but_deleteable_object = false; @@ -4330,6 +4461,12 @@ bool LLSelectMgr::confirmDelete(const LLSD& notification, const LLSD& response, void LLSelectMgr::selectForceDelete() { + if (selectionAllLocalPreview(mSelectedObjects)) + { + deleteLocalPreviewSelection(); + return; + } + sendListToRegions( "ObjectDelete", packDeleteHeader, @@ -4744,6 +4881,9 @@ void LLSelectMgr::packDuplicateOnRayHead(void *user_data) void LLSelectMgr::sendMultipleUpdate(U32 type) { if (type == UPD_NONE) return; + // Client-only local previews are moved/rotated/scaled purely locally; never + // send their transforms to the sim. + if (selectionAllLocalPreview(mSelectedObjects)) return; // send individual updates when selecting textures or individual objects ESendType send_type = (!gSavedSettings.getBOOL("EditLinkedParts") && !getTEMode()) ? SEND_ONLY_ROOTS : SEND_ROOTS_FIRST; if (send_type == SEND_ONLY_ROOTS) @@ -4951,13 +5091,16 @@ void LLSelectMgr::deselectAll() objectp->setVelocity( 0,0,0 ); } - sendListToRegions( - "ObjectDeselect", - packAgentAndSessionID, - packObjectLocalID, - logNoOp, - NULL, - SEND_INDIVIDUALS); + if (!selectionAllLocalPreview(mSelectedObjects)) + { + sendListToRegions( + "ObjectDeselect", + packAgentAndSessionID, + packObjectLocalID, + logNoOp, + NULL, + SEND_INDIVIDUALS); + } removeAll(); @@ -4982,13 +5125,16 @@ void LLSelectMgr::deselectAllForStandingUp() objectp->setVelocity( 0,0,0 ); } - sendListToRegions( - "ObjectDeselect", - packAgentAndSessionID, - packObjectLocalID, - logNoOp, - NULL, - SEND_INDIVIDUALS); + if (!selectionAllLocalPreview(mSelectedObjects)) + { + sendListToRegions( + "ObjectDeselect", + packAgentAndSessionID, + packObjectLocalID, + logNoOp, + NULL, + SEND_INDIVIDUALS); + } removeAll(); @@ -5355,6 +5501,8 @@ void LLSelectMgr::sendSelect() return; } + if (selectionAllLocalPreview(mSelectedObjects)) return; + sendListToRegions( "ObjectSelect", packAgentAndSessionID, @@ -5418,6 +5566,24 @@ void LLSelectMgr::saveSelectedShinyColors() void LLSelectMgr::saveSelectedObjectTextures() { + // Client-only previews never receive the ObjectProperties reply the normal + // path waits on, so invalidating + sendSelect() would leave them permanently + // invalid -- which breaks the selection when a texture/material picker commits. + // Re-affirm the synthesized node instead (re-snapshots textures, keeps mValid). + if (selectionAllLocalPreview(mSelectedObjects)) + { + struct lf : public LLSelectedNodeFunctor + { + virtual bool apply(LLSelectNode* node) + { + synthesizeLocalPreviewNode(node, node->getObject()); + return true; + } + } local_func; + getSelection()->applyToNodes(&local_func); + return; + } + // invalidate current selection so we update saved textures struct f : public LLSelectedNodeFunctor { @@ -5773,6 +5939,15 @@ void LLSelectMgr::sendListToRegions(LLObjectSelectionHandle selected_handle, void *user_data, ESendType send_type) { + // Client-only local mesh previews never talk to the simulator. Short-circuit + // every object message marshalled through here (name/description/permissions/ + // sale/group/owner/click-action/category/shape/flags/...). Local derez, + // duplicate and attach are handled by LLLocalMeshMgr before reaching this path. + if (selectionAllLocalPreview(selected_handle)) + { + return; + } + LLSelectNode* node; LLSelectNode* linkset_root = NULL; LLViewerRegion* last_region; @@ -5970,6 +6145,13 @@ void LLSelectMgr::sendListToRegions(LLObjectSelectionHandle selected_handle, void LLSelectMgr::requestObjectPropertiesFamily(LLViewerObject* object) { + // Client-only local previews have no sim object; their properties are + // synthesized locally in addAsIndividual, so never query the sim. + if (isLocalPreviewObject(object)) + { + return; + } + LLMessageSystem* msg = gMessageSystem; msg->newMessageFast(_PREHASH_RequestObjectPropertiesFamily); @@ -7885,6 +8067,19 @@ bool LLSelectMgr::canSelectObject(LLViewerObject* object, bool ignore_select_own ESelectType selection_type = getSelectTypeForObject(object); if (mSelectedObjects->getObjectCount() > 0 && mSelectedObjects->mSelectType != selection_type) return false; + // Don't allow mixing client-only local mesh previews with real (sim) objects + // in a single selection -- a mixed selection escapes the all-local gating and + // would send the previews' (fake) local IDs to the sim. The selection is kept + // homogeneous by this check, so the first selected object is representative. + if (mSelectedObjects->getObjectCount() > 0) + { + LLViewerObject* selected = mSelectedObjects->getFirstObject(); + if (selected && selected->isLocalOnly() != object->isLocalOnly()) + { + return false; + } + } + return true; } diff --git a/indra/newview/llstartup.cpp b/indra/newview/llstartup.cpp index fc6308dbf8..7c46e9980a 100644 --- a/indra/newview/llstartup.cpp +++ b/indra/newview/llstartup.cpp @@ -70,6 +70,7 @@ #include "llfocusmgr.h" #include "llfloatergridstatus.h" #include "llfloaterimsession.h" +#include "lllocalassetpaths.h" #include "lllocationhistory.h" #include "llgltfmateriallist.h" #include "llimageworker.h" @@ -1200,6 +1201,12 @@ bool idle_startup() LLRenderMuteList::getInstance()->loadFromFile(); + // List the artist's saved local-asset file paths (mesh/anim/texture/material) + // for the Local Assets floater. This only reads the paths -- the files are + // decoded lazily when first used -- so it's fine to do here, and it starts + // watching the managers to keep the saved set current. + LLLocalAssetPaths::getInstance()->loadAndWatch(); + // [SL:KB] - Patch: Control-TextParser | Checked: 2012-09-22 (Catznip-3.3) if (LLTextParser::instance().loadKeywords() && LLTextParser::instance().getHighlightCount() > 0) { diff --git a/indra/newview/lltexturectrl.cpp b/indra/newview/lltexturectrl.cpp index 82045141dc..ca96c56054 100644 --- a/indra/newview/lltexturectrl.cpp +++ b/indra/newview/lltexturectrl.cpp @@ -660,6 +660,15 @@ bool LLFloaterTexturePicker::postBuild() mLocalScrollCtrl->setCommitCallback(onLocalScrollCommit, this); refreshLocalList(); + // React to local-unit changes from anywhere (this picker, another picker, the + // Local Assets floater, a mesh import, or a live-reload) so the Local tab never + // goes stale. refreshLocalList() reads the current pick type, so one handler per + // manager serves textures and materials alike. + mLocalBitmapsChangedConn = LLLocalBitmapMgr::getInstance()->setUnitsChangedCallback( + [this]() { onLocalAssetsChanged(); }); + mLocalMaterialsChangedConn = LLLocalGLTFMaterialMgr::getInstance()->setUnitsChangedCallback( + [this]() { onLocalAssetsChanged(); }); + getChild("uuid_editor")->setCommitCallback(boost::bind(&onApplyUUID, this)); getChild("apply_uuid_btn")->setClickedCallback(boost::bind(&onApplyUUID, this)); @@ -1203,6 +1212,10 @@ void LLFloaterTexturePicker::onBtnRemove(void* userdata) if (list_item) { LLSD data = list_item->getValue(); + if (data["mesh_owned"].asBoolean()) + { + continue; // model-loaded: read-only, owned by its mesh + } LLUUID tracking_id = data["id"]; S32 asset_type = data["type"].asInteger(); @@ -1542,6 +1555,51 @@ void LLFloaterTexturePicker::refreshLocalList() } } +void LLFloaterTexturePicker::onLocalAssetsChanged() +{ + // A refresh can be triggered by another picker / the Local Assets floater adding + // or removing local assets. Capture this picker's Local-tab selection first + // (refreshLocalList() rebuilds and clears the list) so we can either keep it + // highlighted or, if its unit was removed, drop the now-dangling selection. + LLSD sel_value; + bool have_sel = false; + bool still_valid = false; + if (mModeSelector && mModeSelector->getValue().asInteger() == PICKER_LOCAL && mLocalScrollCtrl) + { + if (LLScrollListItem* sel = mLocalScrollCtrl->getFirstSelected()) + { + const LLSD data = sel->getValue(); + if (data.has("id") && data.has("type")) + { + sel_value = data; + have_sel = true; + const LLUUID tracking_id = data["id"].asUUID(); + const LLUUID world_id = (data["type"].asInteger() == LLAssetType::AT_MATERIAL) + ? LLLocalGLTFMaterialMgr::getInstance()->getWorldID(tracking_id) + : LLLocalBitmapMgr::getInstance()->getWorldID(tracking_id); + still_valid = world_id.notNull(); + } + } + } + + refreshLocalList(); + + if (have_sel) + { + if (still_valid) + { + // Keep the user's selection highlighted across the rebuild. + mLocalScrollCtrl->setSelectedByValue(sel_value, true); + } + else + { + // The selected local asset was removed elsewhere; drop the dangling + // selection so a later commit can't apply a deleted texture/material. + mImageAssetID.setNull(); + } + } +} + void LLFloaterTexturePicker::refreshInventoryFilter() { U32 filter_types = 0x0; @@ -1662,26 +1720,10 @@ void LLFloaterTexturePicker::onPickerCallback(const std::vector& fi iter++; } - // Todo: this should referesh all pickers, not just a current one - if (!handle.isDead()) - { - LLFloaterTexturePicker* self = (LLFloaterTexturePicker*)handle.get(); - self->mLocalScrollCtrl->clearRows(); - - if (self->mInventoryPickType == PICK_TEXTURE_MATERIAL) - { - LLLocalBitmapMgr::getInstance()->feedScrollList(self->mLocalScrollCtrl); - LLLocalGLTFMaterialMgr::getInstance()->feedScrollList(self->mLocalScrollCtrl); - } - else if (self->mInventoryPickType == PICK_TEXTURE) - { - LLLocalBitmapMgr::getInstance()->feedScrollList(self->mLocalScrollCtrl); - } - else if (self->mInventoryPickType == PICK_MATERIAL) - { - LLLocalGLTFMaterialMgr::getInstance()->feedScrollList(self->mLocalScrollCtrl); - } - } + // No manual refresh needed: addUnit() fires the manager's units-changed signal, + // and every open picker (this one included) rebuilds its Local tab from the + // handler wired in postBuild() -- so all pickers stay in sync, not just this one. + (void)handle; } void LLFloaterTexturePicker::onTextureSelect(bool success, const LLTextureEntry& te ) diff --git a/indra/newview/lltexturectrl.h b/indra/newview/lltexturectrl.h index daae1cad3c..4f705f5f3a 100644 --- a/indra/newview/lltexturectrl.h +++ b/indra/newview/lltexturectrl.h @@ -43,6 +43,8 @@ #include "llviewertexture.h" #include "llwindow.h" +#include + class LLComboBox; class LLFloaterTexturePicker; class LLInventoryItem; @@ -403,6 +405,7 @@ class LLFloaterTexturePicker : public LLFloater protected: void changeMode(); void refreshLocalList(); + void onLocalAssetsChanged(); // refresh local list, then drop a now-deleted local selection void refreshInventoryFilter(); void setImageIDFromItem(const LLInventoryItem* itemp, bool set_selection = true); LLViewerInventoryItem* findInvItem(const LLUUID& asset_id, bool copyable_only, bool ignore_library = false) const; @@ -470,6 +473,12 @@ class LLFloaterTexturePicker : public LLFloater bool mBakeTextureEnabled; bool mLocalTextureEnabled; + // Keep the Local tab live as local units change anywhere -- the Local Assets + // floater, a mesh import, live-reload, or another picker -- not just after this + // picker's own add/remove. Scoped so they drop when the floater is destroyed. + boost::signals2::scoped_connection mLocalBitmapsChangedConn; + boost::signals2::scoped_connection mLocalMaterialsChangedConn; + static S32 sLastPickerMode; }; diff --git a/indra/newview/llviewerfloaterreg.cpp b/indra/newview/llviewerfloaterreg.cpp index 2bb7c1f90e..b869b6bb7d 100644 --- a/indra/newview/llviewerfloaterreg.cpp +++ b/indra/newview/llviewerfloaterreg.cpp @@ -123,6 +123,7 @@ #include "llfloaterlandholdings.h" #include "llfloaterlinkreplace.h" #include "llfloaterloadprefpreset.h" +#include "llfloaterlocalassets.h" #include "llfloatermap.h" #include "llfloatermarketplace.h" #include "llfloatermarketplacelistings.h" @@ -414,6 +415,7 @@ void LLViewerFloaterReg::registerFloaters() LLFloaterReg::add("event", "floater_event.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); } LLFloaterReg::add("experiences", "floater_experiences.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); + LLFloaterReg::add("local_assets", "floater_local_assets.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("experience_profile", "floater_experienceprofile.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("experience_search", "floater_experience_search.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); diff --git a/indra/newview/llviewerjointattachment.cpp b/indra/newview/llviewerjointattachment.cpp index ca4c4a89e0..4a625a4e71 100644 --- a/indra/newview/llviewerjointattachment.cpp +++ b/indra/newview/llviewerjointattachment.cpp @@ -184,8 +184,11 @@ bool LLViewerJointAttachment::addObject(LLViewerObject* object) // Two instances of the same inventory item attached -- // Request detach, and kill the object in the meantime. + // (Skip for client-only objects: a local mesh preview has no inventory item, + // so its id is null and the dedup would markDead an unrelated null-id local + // attachment and send it an ObjectDetach the sim can't act on.) // [SL:KB] - Patch: Appearance-PhantomAttach | Checked: Catznip-5.0 - if (LLViewerObject* pAttachObj = getAttachedObject(object->getAttachmentItemID())) + if (LLViewerObject* pAttachObj = (!object->isLocalOnly()) ? getAttachedObject(object->getAttachmentItemID()) : nullptr) { LL_INFOS() << "(same object re-attached)" << LL_ENDL; pAttachObj->markDead(); diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 152377de32..d95b50a845 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -120,6 +120,9 @@ #include "llviewergenericmessage.h" #include "llviewerhelp.h" #include "llviewermenufile.h" // init_menu_file() +#include "lllocalmesh.h" +#include "lllocalanim.h" +#include "llcontrolavatar.h" // LLControlAvatar (animesh control av for local anim playback) #include "llviewermessage.h" #include "llviewernetwork.h" #include "llviewerobjectlist.h" @@ -663,6 +666,82 @@ void init_menus() /////////////////// +////////////////// +// LOCAL MESH // +////////////////// + +class LLAdvancedLoadLocalMesh : public view_listener_t +{ + bool handleEvent(const LLSD& userdata) + { + LLFilePickerReplyThread::startPicker( + [](const std::vector& filenames, LLFilePicker::ELoadFilter, LLFilePicker::ESaveFilter) + { + if (!filenames.empty()) + { + LLLocalMeshMgr::getInstance()->addAndSpawn(filenames); + } + }, + LLFilePicker::FFLOAD_MODEL, true); + return true; + } +}; + +// Local animation (M5): play/stop a local .anim/.bvh on the selected animated +// object (animesh). The animation runs on the linkset's control avatar -- which +// exists for any animesh, local preview or not -- so a local anim can be previewed +// on any selected animesh, not only client-only previews. +LLControlAvatar* get_selected_animesh_control_avatar() +{ + LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); + if (!obj) + { + return nullptr; + } + LLViewerObject* root = obj->getRootEdit(); + return (root && root->isAnimatedObject()) ? root->getControlAvatar() : nullptr; +} + +bool enable_play_local_anim() +{ + return get_selected_animesh_control_avatar() != nullptr; +} + +void handle_play_local_anim() +{ + LLControlAvatar* cav = get_selected_animesh_control_avatar(); + if (!cav) + { + return; + } + // Keep the control avatar alive across the async file picker; if the preview is + // taken down (or un-animeshed) before the picker returns, bail. + LLPointer cav_ptr = cav; + LLFilePickerReplyThread::startPicker( + [cav_ptr](const std::vector& filenames, LLFilePicker::ELoadFilter, LLFilePicker::ESaveFilter) + { + if (filenames.empty() || cav_ptr.isNull() || cav_ptr->isDead()) + { + return; + } + const LLUUID anim_id = LLLocalAnimMgr::getInstance()->loadAnim(filenames.front()); + if (anim_id.notNull()) + { + LLLocalAnimMgr::getInstance()->playOnAvatar(cav_ptr.get(), anim_id); + } + }, + LLFilePicker::FFLOAD_ANIM, false); +} + +void handle_stop_local_anim() +{ + LLControlAvatar* cav = get_selected_animesh_control_avatar(); + if (cav && LLLocalAnimMgr::instanceExists()) + { + LLLocalAnimMgr::getInstance()->stopOnAvatar(cav); + } +} + class LLAdvancedToggleConsole : public view_listener_t { bool handleEvent(const LLSD& userdata) @@ -7597,6 +7676,7 @@ class LLObjectAttachToAvatar : public view_listener_t if (selectedObject) { S32 index = userdata.asInteger(); + LLViewerJointAttachment* attachment_point = NULL; if (index > 0) attachment_point = get_if_there(gAgentAvatarp->mAttachmentPoints, index, (LLViewerJointAttachment*)NULL); @@ -7613,6 +7693,19 @@ class LLObjectAttachToAvatar : public view_listener_t } // [/RLVa:KB] + // Client-only local mesh previews have no inventory item or sim object, + // and there's nothing to walk to -- the normal sim attach path below + // would do nothing. Attach the preview linkset client-side to the + // attachment point the user picked from this menu (render order is sorted + // by attachment-point id, so the choice matters). Kept AFTER the RLVa gate + // above so attach restrictions apply to previews too. + if (selectedObject->isLocalOnly() && LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->attachPreviewToAvatar(selectedObject, index, mReplace); + setObjectSelection(NULL); + return true; + } + confirmReplaceAttachment(0, attachment_point); } return true; @@ -7883,6 +7976,25 @@ class LLAttachmentDetach : public view_listener_t return true; } + // Respect RLVa remove-locks for local previews too: if @detach-locked and the + // selection sits on a locked attachment point, don't detach. + if ( (rlv_handler_t::isEnabled()) && (gRlvAttachmentLocks.hasLockedAttachmentPoint(RLV_LOCK_REMOVE)) ) + { + LLObjectSelectionHandle hSelect = LLSelectMgr::getInstance()->getSelection(); + RlvSelectHasLockedAttach f; + if ( (hSelect->isAttachment()) && (hSelect->getFirstRootNode(&f, false) != NULL) ) + return true; + } + + // Client-only local mesh previews have no inventory item or sim object, so + // the item-id based detach below is a no-op for them. Route to the local + // mesh manager, which detaches the whole preview linkset client-side. + if (object->isLocalOnly() && LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->detachPreviewFromAvatar(object); + return true; + } + struct f: public LLSelectedObjectFunctor { f() : mAvatarsInSelection(false) {} @@ -10411,6 +10523,7 @@ void initialize_menus() view_listener_t::addMenu(new LLToggleHowTo(), "Help.ToggleHowTo"); // Advanced menu + view_listener_t::addMenu(new LLAdvancedLoadLocalMesh(), "Advanced.LoadLocalMesh"); view_listener_t::addMenu(new LLAdvancedToggleConsole(), "Advanced.ToggleConsole"); view_listener_t::addMenu(new LLAdvancedCheckConsole(), "Advanced.CheckConsole"); view_listener_t::addMenu(new LLAdvancedDumpInfoToConsole(), "Advanced.DumpInfoToConsole"); @@ -10677,6 +10790,8 @@ void initialize_menus() commit.add("Object.SetFavorite", boost::bind(&handle_object_set_favorite, _2)); commit.add("Object.SitOrStand", boost::bind(&handle_object_sit_or_stand)); commit.add("Object.Delete", boost::bind(&handle_object_delete)); + commit.add("Object.PlayLocalAnim", boost::bind(&handle_play_local_anim)); + commit.add("Object.StopLocalAnim", boost::bind(&handle_stop_local_anim)); view_listener_t::addMenu(new LLObjectAttachToAvatar(true), "Object.AttachToAvatar"); view_listener_t::addMenu(new LLObjectAttachToAvatar(false), "Object.AttachAddToAvatar"); view_listener_t::addMenu(new LLObjectReturn(), "Object.Return"); @@ -10705,6 +10820,7 @@ void initialize_menus() enable.add("Object.EnableTouch", boost::bind(&enable_object_touch, _1)); enable.add("Object.EnableFavorites", boost::bind(&enable_object_favorite, _2)); enable.add("Object.EnableDelete", boost::bind(&enable_object_delete)); + enable.add("Object.EnablePlayLocalAnim", boost::bind(&enable_play_local_anim)); enable.add("Object.EnableWear", boost::bind(&object_is_wearable)); enable.add("Object.EnableStandUp", boost::bind(&enable_object_stand_up)); diff --git a/indra/newview/llviewermenu.h b/indra/newview/llviewermenu.h index 2045023440..389696944e 100644 --- a/indra/newview/llviewermenu.h +++ b/indra/newview/llviewermenu.h @@ -37,6 +37,7 @@ class LLView; class LLParcelSelection; class LLObjectSelection; class LLSelectNode; +class LLControlAvatar; // [RLVa:KB] - Checked: RLVa-2.0.0 void set_use_wireframe(bool useWireframe); @@ -83,6 +84,11 @@ void handle_object_delete(); void handle_object_edit(); void handle_object_edit_gltf_material(); +// Resolve the control avatar of the currently-selected animesh (any animesh, local +// preview or not), or null. Shared by the in-world right-click menu and the Local +// Assets floater so a local anim can be previewed on any animesh. +LLControlAvatar* get_selected_animesh_control_avatar(); + void handle_attachment_edit(const LLUUID& inv_item_id); void handle_attachment_touch(const LLUUID& inv_item_id); bool enable_attachment_touch(const LLUUID& inv_item_id); diff --git a/indra/newview/llviewerobject.cpp b/indra/newview/llviewerobject.cpp index 32e6ecf08d..ad803fda27 100644 --- a/indra/newview/llviewerobject.cpp +++ b/indra/newview/llviewerobject.cpp @@ -263,6 +263,7 @@ LLViewerObject::LLViewerObject(const LLUUID &id, const LLPCode pcode, LLViewerRe mTENormalMaps(NULL), mTESpecularMaps(NULL), mbCanSelect(true), + mIsLocalOnly(false), mFlags(0), mPhysicsShapeType(0), mPhysicsGravity(0), @@ -2813,6 +2814,8 @@ void LLViewerObject::saveScript(const LLViewerInventoryItem* item, * XXXPAM Investigate not making this copy. Seems unecessary, but I'm unsure about the * interaction with doUpdateInventory() called below. */ + if (isLocalOnly()) return; // client-only object: no task inventory on a sim + LL_DEBUGS() << "LLViewerObject::saveScript() " << item->getUUID() << " " << item->getAssetUUID() << LL_ENDL; LLPointer task_item = @@ -2854,6 +2857,8 @@ void LLViewerObject::saveScript(const LLViewerInventoryItem* item, void LLViewerObject::moveInventory(const LLUUID& folder_id, const LLUUID& item_id) { + if (isLocalOnly()) return; // client-only object: no task inventory on a sim + LL_DEBUGS() << "LLViewerObject::moveInventory " << item_id << LL_ENDL; LLMessageSystem* msg = gMessageSystem; msg->newMessageFast(_PREHASH_MoveTaskInventory); @@ -2961,6 +2966,20 @@ void LLViewerObject::requestInventory() void LLViewerObject::fetchInventoryFromServer() { + if (isLocalOnly()) + { + // Client-only object (e.g. local mesh preview): no task inventory exists + // on any sim, and mLocalID is client-side -- a request could even hit an + // unrelated region object. Present an empty, loaded inventory instead. + if (!mInventory) + { + mInventory = new LLInventoryObject::object_list_t(); + } + mInventoryDirty = false; + doInventoryCallback(); // resets mInvRequestState to STOPPED + return; + } + if (!isInventoryPending()) { delete mInventory; @@ -3563,6 +3582,8 @@ void LLViewerObject::doInventoryCallback() void LLViewerObject::removeInventory(const LLUUID& item_id) { + if (isLocalOnly()) return; // client-only object: no task inventory on a sim + // close associated floater properties LLSD params; params["id"] = item_id; @@ -3613,6 +3634,13 @@ void LLViewerObject::updateMaterialInventory(LLViewerInventoryItem* item, U8 key { return; } + if (isLocalOnly()) + { + // Client-only object: no server-side task inventory, so don't queue the + // asset as pending -- updateInventory() would early-out and never clear + // it, wedging later isAssetInInventory() short-circuits for that asset. + return; + } if (LLAssetType::AT_TEXTURE != item->getType() && LLAssetType::AT_MATERIAL != item->getType()) { @@ -3635,6 +3663,8 @@ void LLViewerObject::updateInventory( U8 key, bool is_new) { + if (isLocalOnly()) return; // client-only object: no task inventory on a sim + // This slices the object into what we're concerned about on the // viewer. The simulator will take the permissions and transfer // ownership. @@ -4971,6 +5001,7 @@ void LLViewerObject::setNumTEs(const U8 num_tes) void LLViewerObject::sendMaterialUpdate() const { + if (isLocalOnly()) return; // client-only object: no sim counterpart LLViewerRegion* regionp = getRegion(); if(!regionp) return; gMessageSystem->newMessageFast(_PREHASH_ObjectMaterial); @@ -4987,6 +5018,7 @@ void LLViewerObject::sendMaterialUpdate() const //formerly send_object_shape(LLViewerObject *object) void LLViewerObject::sendShapeUpdate() { + if (isLocalOnly()) return; // client-only object: no sim counterpart gMessageSystem->newMessageFast(_PREHASH_ObjectShape); gMessageSystem->nextBlockFast(_PREHASH_AgentData); gMessageSystem->addUUIDFast(_PREHASH_AgentID, gAgent.getID() ); @@ -5003,6 +5035,7 @@ void LLViewerObject::sendShapeUpdate() void LLViewerObject::sendTEUpdate() const { + if (isLocalOnly()) return; // client-only object: no sim counterpart LLMessageSystem* msg = gMessageSystem; msg->newMessageFast(_PREHASH_ObjectImage); @@ -6501,7 +6534,8 @@ void LLViewerObject::parameterChanged(U16 param_type, bool local_origin) void LLViewerObject::parameterChanged(U16 param_type, LLNetworkData* data, bool in_use, bool local_origin) { - if (local_origin) + // Client-only objects have no sim counterpart -- never send param changes up. + if (local_origin && !isLocalOnly()) { // *NOTE: Do not send the render material ID in this way as it will get // out-of-sync with other sent client data. @@ -6900,6 +6934,7 @@ bool LLViewerObject::specialHoverCursor() const void LLViewerObject::updateFlags(bool physics_changed) { + if (isLocalOnly()) return; // client-only object: no sim counterpart LLViewerRegion* regionp = getRegion(); if(!regionp) return; gMessageSystem->newMessageFast(_PREHASH_ObjectFlagUpdate); @@ -7493,9 +7528,11 @@ void LLViewerObject::setRenderMaterialID(S32 te_in, const LLUUID& id, bool updat } } - if (update_server) + if (update_server && !isLocalOnly()) { - // update via ModifyMaterialParams cap (server will echo back changes) + // update via ModifyMaterialParams cap (server will echo back changes). + // Client-only objects have no sim counterpart, so the local render + // material above is the end of it. for (S32 te = start_idx; te < end_idx; ++te) { // This sends a cleared version of this object's current material @@ -7653,6 +7690,7 @@ bool LLViewerObject::isObjectInPendingUpdate(const LLUUID& owner_id, LLViewerObj void LLViewerObject::requestObjectUpdate() { + if (isLocalOnly()) return; // client-only object: nothing to request from a sim if (LLViewerRegion* regionp = getRegion()) { LLMessageSystem* msg = gMessageSystem; diff --git a/indra/newview/llviewerobject.h b/indra/newview/llviewerobject.h index 4abfb714e1..05c322a78c 100644 --- a/indra/newview/llviewerobject.h +++ b/indra/newview/llviewerobject.h @@ -440,6 +440,10 @@ class LLViewerObject // [RLVa:KB] - Checked: 2010-02-27 (RLVa-1.2.0a) | Added: RLVa-1.2.0a U8 getAttachmentState() const { return mAttachmentState; } // [/RLVa:KB] + // Set the encoded attachment-point state. Normally written from the server's + // object update; exposed so a client-only object (local mesh preview) can be + // attached to the agent avatar without a round-trip. + void setAttachmentState(U8 state) { mAttachmentState = state; } // U8 getAttachmentState() { return mAttachmentState; } F32 getAppAngle() const { return mAppAngle; } @@ -778,6 +782,13 @@ class LLViewerObject // can likely be factored out bool mbCanSelect; + // Client-only object with no simulator counterpart (e.g. a local mesh + // preview). Build/select code reads this to skip all server traffic for the + // object and to delete it locally instead of asking the sim. O(1) -- avoids + // having to ask the owning manager whether an object is client-only. + bool mIsLocalOnly; + bool isLocalOnly() const { return mIsLocalOnly; } + private: // Grabbed from UPDATE_FLAGS U32 mFlags; diff --git a/indra/newview/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index 34ead9c963..5fde88cc65 100644 --- a/indra/newview/llviewerobjectlist.cpp +++ b/indra/newview/llviewerobjectlist.cpp @@ -65,6 +65,7 @@ #include "lltoolmgr.h" #include "lltoolpie.h" #include "llkeyboard.h" +#include "lllocalmesh.h" #include "llmeshrepository.h" #include "u64.h" #include "llviewertexturelist.h" @@ -1412,6 +1413,14 @@ bool LLViewerObjectList::killObject(LLViewerObject *objectp) void LLViewerObjectList::killObjects(LLViewerRegion *regionp) { LL_PROFILE_ZONE_SCOPED; + + // Release our client-only local mesh previews hosted by this region before it + // is torn down, so they don't dangle past it (the preview avatar too). + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->despawnObjectsInRegion(regionp); + } + LLViewerObject *objectp; @@ -1433,6 +1442,14 @@ void LLViewerObjectList::killAllObjects() { // Used only on global destruction. + // Destroy local-mesh preview objects (and their preview avatar) first, so + // these client-only objects are released while this list is still valid + // instead of dangling until the local-mesh singleton is torn down. + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->cleanup(); + } + // Mass cleanup to not clear lists one item at a time mIndexAndLocalIDToUUID.clear(); mActiveObjects.clear(); @@ -1594,6 +1611,15 @@ void LLViewerObjectList::updateActive(LLViewerObject *objectp) void LLViewerObjectList::updateObjectCost(LLViewerObject* object) { + // Client-only (local preview) objects have no sim counterpart, so their fake + // UUIDs can't be costed by the GetObjectCost capability: a request would never + // resolve and would re-fire every time the cost is read. Skip them -- their + // cached cost stays at the 0 default, which is the correct land impact for + // something that isn't actually rezzed on the region. + if (!object || object->isLocalOnly()) + { + return; + } if (!object->isRoot()) { //always fetch cost for the parent when fetching cost for children mStaleObjectCost.insert(((LLViewerObject*)object->getParent())->getID()); @@ -1621,6 +1647,13 @@ void LLViewerObjectList::onObjectCostFetchFailure(const LLUUID& object_id) void LLViewerObjectList::updatePhysicsFlags(const LLViewerObject* object) { + // Client-only (local preview) objects have no sim counterpart, so the + // GetObjectPhysicsData cap can't resolve their fake UUIDs -- skip them, mirroring + // updateObjectCost() above. + if (!object || object->isLocalOnly()) + { + return; + } mStalePhysicsFlags.insert(object->getID()); } diff --git a/indra/newview/llviewershadermgr.cpp b/indra/newview/llviewershadermgr.cpp index 8c8d77ec76..dea4bd25cb 100644 --- a/indra/newview/llviewershadermgr.cpp +++ b/indra/newview/llviewershadermgr.cpp @@ -281,81 +281,6 @@ static void add_common_permutations(LLGLSLShader* shader) } } - -static bool make_gltf_variant(LLGLSLShader& shader, LLGLSLShader& variant, bool alpha_blend, bool rigged, bool unlit, bool multi_uv, bool use_sun_shadow) -{ - variant.mName = shader.mName.c_str(); - variant.mFeatures = shader.mFeatures; - variant.mShaderFiles = shader.mShaderFiles; - variant.mShaderLevel = shader.mShaderLevel; - variant.mShaderGroup = shader.mShaderGroup; - - variant.mDefines = shader.mDefines; // NOTE: Must come before addPermutation - - U32 node_size = 16 * 3; - U32 max_nodes = gGLManager.mMaxUniformBlockSize / node_size; - variant.addPermutation("MAX_NODES_PER_GLTF_OBJECT", std::to_string(max_nodes)); - - U32 material_size = 16 * 12; - U32 max_materials = gGLManager.mMaxUniformBlockSize / material_size; - LLGLSLShader::sMaxGLTFMaterials = max_materials; - - variant.addPermutation("MAX_MATERIALS_PER_GLTF_OBJECT", std::to_string(max_materials)); - - U32 max_vec4s = gGLManager.mMaxUniformBlockSize / 16; - variant.addPermutation("MAX_UBO_VEC4S", std::to_string(max_vec4s)); - - if (rigged) - { - variant.addPermutation("HAS_SKIN", "1"); - } - - if (unlit) - { - variant.addPermutation("UNLIT", "1"); - } - - if (multi_uv) - { - variant.addPermutation("MULTI_UV", "1"); - } - - if (alpha_blend) - { - variant.addPermutation("ALPHA_BLEND", "1"); - - variant.mFeatures.calculatesLighting = false; - variant.mFeatures.hasLighting = false; - variant.mFeatures.isAlphaLighting = true; - variant.mFeatures.hasSrgb = true; - variant.mFeatures.calculatesAtmospherics = true; - variant.mFeatures.hasAtmospherics = true; - variant.mFeatures.hasGamma = true; - variant.mFeatures.hasShadows = use_sun_shadow; - variant.mFeatures.isDeferred = true; // include deferredUtils - variant.mFeatures.hasReflectionProbes = true; - - if (use_sun_shadow) - { - variant.addPermutation("HAS_SUN_SHADOW", "1"); - } - - bool success = variant.createShader(); - llassert(success); - - // Alpha Shader Hack - // See: LLRender::syncMatrices() - variant.mFeatures.calculatesLighting = true; - variant.mFeatures.hasLighting = true; - - return success; - } - else - { - return variant.createShader(); - } -} - #ifdef SHOW_ASSERT // return true if there are no redundant shaders in the given vector // also checks for redundant variants diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 1bf8bca967..60968232d2 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -129,6 +129,7 @@ #include "llkeyboard.h" #include "lllineeditor.h" #include "lllocalbitmaps.h" +#include "llfloaterlocalassets.h" #include "lllocalgltfmaterials.h" #include "llmenugl.h" #include "llmenuoptionpathfindingrebakenavmesh.h" @@ -1487,6 +1488,34 @@ LLWindowCallbacks::DragNDropResult LLViewerWindow::handleDragNDropFile(LLWindow case LLWindowCallbacks::DNDA_TRACK: case LLWindowCallbacks::DNDA_DROPPED: { + // Route a file drop landing on the Local Assets floater into its tabs, + // ahead of the world/upload paths (works in or out of build mode). `pos` + // is in raw GL device pixels but UI rects are in scaled UI coords, so + // convert before the hit-test (mirrors the mouse path, see ~line 1032). + if (!mDragItems.empty()) + { + LLFloater* la = LLFloaterReg::findInstance("local_assets"); + const S32 ui_x = ll_round((F32)pos.mX / mDisplayScale.mV[VX]); + const S32 ui_y = ll_round((F32)pos.mY / mDisplayScale.mV[VY]); + if (la && la->isInVisibleChain() && la->calcScreenRect().pointInRect(ui_x, ui_y)) + { + result = LLWindowCallbacks::DND_COPY; + if (fDrop) + { + std::vector files; + for (const drag_item_t& dragItem : mDragItems) + { + files.push_back(dragItem.second); + } + if (LLFloaterLocalAssets* laf = dynamic_cast(la)) + { + laf->dropFiles(files); + } + } + break; // handled by the floater + } + } + if (!LLToolMgr::getInstance()->inBuildMode()) { if (!mDragItems.empty()) diff --git a/indra/newview/llvoavatarself.cpp b/indra/newview/llvoavatarself.cpp index 7e0a0ce9cd..c1ac0409ce 100644 --- a/indra/newview/llvoavatarself.cpp +++ b/indra/newview/llvoavatarself.cpp @@ -1264,25 +1264,33 @@ const LLViewerJointAttachment *LLVOAvatarSelf::attachObject(LLViewerObject *view // Should just be the last object added if (attachment->isObjectAttached(viewer_object)) { - const LLUUID& attachment_id = viewer_object->getAttachmentItemID(); - LLAppearanceMgr::instance().registerAttachment(attachment_id); updateLODRiggedAttachments(); -// [RLVa:KB] - Checked: 2010-08-22 (RLVa-1.2.1a) | Modified: RLVa-1.2.1a - // NOTE: RLVa event handlers should be invoked *after* LLVOAvatar::attachObject() calls LLViewerJointAttachment::addObject() - if (mAttachmentSignal) - { - (*mAttachmentSignal)(viewer_object, attachment, ACTION_ATTACH); - } - if (rlv_handler_t::isEnabled()) + // Client-only objects (e.g. local mesh previews) have no inventory item + // and never participate in COF or RLV state. Do the rigged-render refresh + // above for them, but skip the inventory sync and RLV/appearance + // bookkeeping a real attachment would trigger. + if (!viewer_object->isLocalOnly()) { - RlvAttachmentLockWatchdog::instance().onAttach(viewer_object, attachment); - gRlvHandler.onAttach(viewer_object, attachment); + const LLUUID& attachment_id = viewer_object->getAttachmentItemID(); + LLAppearanceMgr::instance().registerAttachment(attachment_id); - if ( (attachment->getIsHUDAttachment()) && (!gRlvAttachmentLocks.hasLockedHUD()) ) - gRlvAttachmentLocks.updateLockedHUD(); - } +// [RLVa:KB] - Checked: 2010-08-22 (RLVa-1.2.1a) | Modified: RLVa-1.2.1a + // NOTE: RLVa event handlers should be invoked *after* LLVOAvatar::attachObject() calls LLViewerJointAttachment::addObject() + if (mAttachmentSignal) + { + (*mAttachmentSignal)(viewer_object, attachment, ACTION_ATTACH); + } + if (rlv_handler_t::isEnabled()) + { + RlvAttachmentLockWatchdog::instance().onAttach(viewer_object, attachment); + gRlvHandler.onAttach(viewer_object, attachment); + + if ( (attachment->getIsHUDAttachment()) && (!gRlvAttachmentLocks.hasLockedHUD()) ) + gRlvAttachmentLocks.updateLockedHUD(); + } // [/RLVa:KB] + } } return attachment; @@ -1292,10 +1300,13 @@ const LLViewerJointAttachment *LLVOAvatarSelf::attachObject(LLViewerObject *view bool LLVOAvatarSelf::detachObject(LLViewerObject *viewer_object) { const LLUUID attachment_id = viewer_object->getAttachmentItemID(); + // Client-only objects (e.g. local mesh previews) have no inventory item and + // never participate in COF or RLV state -- skip that bookkeeping below. + const bool is_local = viewer_object->isLocalOnly(); // [RLVa:KB] - Checked: 2010-03-05 (RLVa-1.2.0a) | Added: RLVa-1.2.0a // NOTE: RLVa event handlers should be invoked *before* LLVOAvatar::detachObject() calls LLViewerJointAttachment::removeObject() - if (rlv_handler_t::isEnabled()) + if (rlv_handler_t::isEnabled() && !is_local) { for (attachment_map_t::const_iterator itAttachPt = mAttachmentPoints.begin(); itAttachPt != mAttachmentPoints.end(); ++itAttachPt) { @@ -1336,7 +1347,11 @@ bool LLVOAvatarSelf::detachObject(LLViewerObject *viewer_object) // Make sure the inventory is in sync with the avatar. // Update COF contents, don't trigger appearance update. - if (!isAgentAvatarValid()) + if (is_local) + { + // No inventory item to unregister for a client-only preview. + } + else if (!isAgentAvatarValid()) { LL_INFOS() << "removeItemLinks skipped, avatar is under destruction" << LL_ENDL; } diff --git a/indra/newview/llvovolume.cpp b/indra/newview/llvovolume.cpp index 117cbcd931..8a38615614 100644 --- a/indra/newview/llvovolume.cpp +++ b/indra/newview/llvovolume.cpp @@ -2576,7 +2576,7 @@ LLVector3 LLVOVolume::getApproximateFaceNormal(U8 face_id) void LLVOVolume::requestMediaDataUpdate(bool isNew) { - if (sObjectMediaClient) + if (sObjectMediaClient && !isLocalOnly()) // client-only object: no sim media data sObjectMediaClient->fetchMedia(new LLMediaDataClientObjectImpl(this, isNew)); } @@ -2825,7 +2825,7 @@ void LLVOVolume::mediaNavigated(LLViewerMediaImpl *impl, LLPluginClassMedia* plu // "bounce back" to the current URL from the media entry mediaNavigateBounceBack(face_index); } - else if (sObjectMediaNavigateClient) + else if (sObjectMediaNavigateClient && !isLocalOnly()) // client-only object: no sim to notify { LL_DEBUGS("MediaOnAPrim") << "broadcasting navigate with URI " << new_location << LL_ENDL; @@ -2915,7 +2915,7 @@ void LLVOVolume::mediaEvent(LLViewerMediaImpl *impl, LLPluginClassMedia* plugin, void LLVOVolume::sendMediaDataUpdate() { - if (sObjectMediaClient) + if (sObjectMediaClient && !isLocalOnly()) // client-only object: no sim media data sObjectMediaClient->updateMedia(new LLMediaDataClientObjectImpl(this, false)); } diff --git a/indra/newview/llvovolume.h b/indra/newview/llvovolume.h index b6e3de7f8f..8f6bea299b 100644 --- a/indra/newview/llvovolume.h +++ b/indra/newview/llvovolume.h @@ -141,6 +141,7 @@ class LLVOVolume : public LLViewerObject /*virtual*/ bool setParent(LLViewerObject* parent) override; S32 getLOD() const override { return mLOD; } void setNoLOD() { mLOD = NO_LOD; mLODChanged = true; } + void setLOD(S32 lod) { mLOD = lod; mLODChanged = true; } bool isNoLOD() const { return NO_LOD == mLOD; } const LLVector3 getPivotPositionAgent() const override; const LLMatrix4& getRelativeXform() const { return mRelativeXform; } diff --git a/indra/newview/rlvlocks.cpp b/indra/newview/rlvlocks.cpp index 531dae03de..c3c59a14dd 100644 --- a/indra/newview/rlvlocks.cpp +++ b/indra/newview/rlvlocks.cpp @@ -550,6 +550,12 @@ void RlvAttachmentLockWatchdog::detach(S32 idxAttachPt, const uuid_vec_t& idsAtt // Checked: 2010-09-23 (RLVa-1.2.1d) | Modified: RLVa-1.2.1d void RlvAttachmentLockWatchdog::onAttach(const LLViewerObject* pAttachObj, const LLViewerJointAttachment* pAttachPt) { + // Client-only objects (e.g. local mesh previews) have no inventory item and + // never participate in attachment locks. Their null attachment-item id would + // trip the RLV_ASSERT below, which is fatal (LL_ERRS) in debug-info builds. + if (!pAttachObj || pAttachObj->isLocalOnly()) + return; + S32 idxAttachPt = RlvAttachPtLookup::getAttachPointIndex(pAttachObj); const LLUUID& idAttachItem = (pAttachObj) ? pAttachObj->getAttachmentItemID() : LLUUID::null; RLV_ASSERT( (!isAgentAvatarValid()) || ((idxAttachPt) && (idAttachItem.notNull())) ); @@ -657,6 +663,10 @@ void RlvAttachmentLockWatchdog::onAttach(const LLViewerObject* pAttachObj, const // Checked: 2010-07-28 (RLVa-1.2.0i) | Modified: RLVa-1.2.0i void RlvAttachmentLockWatchdog::onDetach(const LLViewerObject* pAttachObj, const LLViewerJointAttachment* pAttachPt) { + // See onAttach(): client-only objects never participate in attachment locks. + if (!pAttachObj || pAttachObj->isLocalOnly()) + return; + S32 idxAttachPt = RlvAttachPtLookup::getAttachPointIndex(pAttachPt); const LLUUID& idAttachItem = (pAttachObj) ? pAttachObj->getAttachmentItemID() : LLUUID::null; RLV_ASSERT( (!isAgentAvatarValid()) || ((idxAttachPt) && (idAttachItem.notNull())) ); diff --git a/indra/newview/skins/default/textures/textures.xml b/indra/newview/skins/default/textures/textures.xml index 8240680535..dbaffd6995 100644 --- a/indra/newview/skins/default/textures/textures.xml +++ b/indra/newview/skins/default/textures/textures.xml @@ -151,6 +151,7 @@ with the same filename but different name + diff --git a/indra/newview/skins/default/textures/toolbar_icons/local_assets.png b/indra/newview/skins/default/textures/toolbar_icons/local_assets.png new file mode 100644 index 0000000000..115c82c66b Binary files /dev/null and b/indra/newview/skins/default/textures/toolbar_icons/local_assets.png differ diff --git a/indra/newview/skins/default/xui/en/floater_local_assets.xml b/indra/newview/skins/default/xui/en/floater_local_assets.xml new file mode 100644 index 0000000000..f89cf5a3ab --- /dev/null +++ b/indra/newview/skins/default/xui/en/floater_local_assets.xml @@ -0,0 +1,28 @@ + + + Mesh + Rezzed + Animations + Textures + Materials + + diff --git a/indra/newview/skins/default/xui/en/menu_local_mesh.xml b/indra/newview/skins/default/xui/en/menu_local_mesh.xml new file mode 100644 index 0000000000..987c9f5c02 --- /dev/null +++ b/indra/newview/skins/default/xui/en/menu_local_mesh.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_object.xml b/indra/newview/skins/default/xui/en/menu_object.xml index abbae4d9c9..1bfefb91fc 100644 --- a/indra/newview/skins/default/xui/en/menu_object.xml +++ b/indra/newview/skins/default/xui/en/menu_object.xml @@ -22,6 +22,22 @@ + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml index bd7597bf04..4ea2a4b725 100644 --- a/indra/newview/skins/default/xui/en/menu_viewer.xml +++ b/indra/newview/skins/default/xui/en/menu_viewer.xml @@ -1287,6 +1287,14 @@ function="World.EnvPreset" name="BuildTools" tear_off="true" visible="true"> + + + + + + + + +[FNAME] has no UV coordinates, so textures and materials can't be previewed on it (it renders untextured/white). Re-export the mesh with a UV map. + + + + Rez in World + Derez + Apply to Selected + Apply to Selected + Rez a new in-world copy of this mesh (a client-side preview; nothing is uploaded). + Apply this to the selected object (all faces), or only the face(s) picked with Select Face. + Name + Status + My Avatar + Selected Object + No meshes yet. Click "Add..." to load a .dae, .gltf or .glb model. + No animations yet. Click "Add..." to load a .bvh or .anim file. + No textures yet. Click "Add..." to load an image file. + No materials yet. Click "Add..." to load a .gltf or .glb material. + + + + +