From 37d16bde3fdbdaba2be764f680bae16a8f13bcad Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 2 Jun 2026 23:13:47 -0400 Subject: [PATCH 01/58] Add local mesh preview: load DAE/glTF from disk and render in-world A "Local Mesh" feature -- the mesh analog of Local Bitmap. Load a Collada (.dae) or glTF (.gltf/.glb) file from disk using the same loaders as the mesh upload path, decode it to in-memory geometry (+ skin), assign it a client-only "world" UUID, and render it in-world without uploading to the asset server. - LLLocalMesh / LLLocalMeshMgr (lllocalmesh.h/.cpp): parse a model file on the model-loader worker thread and, on the main-thread callback, assemble the high-LOD geometry into a single LLVolume (served for every LOD) plus an LLMeshSkinInfo if rigged. Units are tracked by tracking ID / world UUID. The loader self-deletes (LLModelLoader::loadModelCallback), so only the per-load context is freed. The registry is created at LLMeshRepository::init(). - LLMeshRepository injection: loadMesh / getActualMeshLOD / hasHeader / hasSkinInfo / getSkinInfo short-circuit local world IDs to serve the decoded volume/skin instead of issuing an asset fetch (guarded by instanceExists() for shutdown safety). - spawnInWorld: create a client-only LL_PCODE_VOLUME via createObjectViewer, mark it as a mesh (PARAMS_SCULPT) with a forced LOD so the repository injection serves the geometry with the correct face count, give each face a default texture, and register it with the render pipeline. Spawned objects are tracked and torn down with their unit. Not selectable yet. - Develop > Load Local Mesh...: file-pick a model and spawn it in-world (temporary trigger ahead of the dedicated UI floater). - LLVOVolume::setLOD(): force a specific LOD on a client-only volume. Follow-ups: build-tool selection/manipulation, file textures/materials, live reload, rigged-to-avatar, animesh, and the UI floater. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/CMakeLists.txt | 2 + indra/newview/lllocalmesh.cpp | 720 ++++++++++++++++++ indra/newview/lllocalmesh.h | 156 ++++ indra/newview/llmeshrepository.cpp | 61 ++ indra/newview/llviewermenu.cpp | 23 + indra/newview/llvovolume.h | 1 + .../skins/default/xui/en/menu_viewer.xml | 6 + 7 files changed, 969 insertions(+) create mode 100644 indra/newview/lllocalmesh.cpp create mode 100644 indra/newview/lllocalmesh.h diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 691831de41..76f1caab2b 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -513,6 +513,7 @@ set(viewer_SOURCE_FILES lllistview.cpp lllocalbitmaps.cpp lllocalgltfmaterials.cpp + lllocalmesh.cpp lllocationhistory.cpp lllocationinputctrl.cpp lllogchat.cpp @@ -1276,6 +1277,7 @@ set(viewer_HEADER_FILES lllistview.h lllocalbitmaps.h lllocalgltfmaterials.h + lllocalmesh.h lllocationhistory.h lllocationinputctrl.h lllogchat.h diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp new file mode 100644 index 0000000000..6ac43dd314 --- /dev/null +++ b/indra/newview/lllocalmesh.cpp @@ -0,0 +1,720 @@ +/** + * @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" + +/* 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 "llvolume.h" +#include "llvolumemgr.h" // LLVolumeLODGroup + +/* viewer */ +#include "fsyspath.h" +#include "indra_constants.h" // IMG_DEFAULT +#include "llagent.h" +#include "llcallbacklist.h" // doOnIdleOneTime +#include "llinventoryicon.h" +#include "llprimitive.h" // LL_PCODE_VOLUME +#include "llscrolllistctrl.h" +#include "llviewercontrol.h" +#include "llviewerobjectlist.h" +#include "llviewerregion.h" +#include "llvoavatarself.h" +#include "llvovolume.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; + }; + + // Build the joint alias map the loaders use to recognise rig joints, + // mirroring LLModelPreview::getJointAliases() but against the agent avatar. + void buildJointAliases(JointMap& joint_map) + { + if (!isAgentAvatarValid()) + { + return; + } + + joint_map = gAgentAvatarp->getJointAliases(); + + std::vector cv_names, attach_names; + gAgentAvatarp->getSortedJointNames(1, cv_names); + gAgentAvatarp->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; + } + } + + // Transform a volume face's positions/normals in place and recompute extents. + // Normals use the 3x3 rotation only (renormalized); adequate for preview and + // exact for rigid/uniformly-scaled instances. + void transformFace(LLVolumeFace& face, const LLMatrix4a& mat) + { + if (face.mNumVertices <= 0 || !face.mPositions) + { + return; + } + + LLVector4a min, max; + for (S32 i = 0; i < face.mNumVertices; ++i) + { + LLVector4a p; + mat.affineTransform(face.mPositions[i], p); + face.mPositions[i] = p; + + if (face.mNormals) + { + LLVector4a n; + mat.rotate(face.mNormals[i], n); + n.normalize3fast(); + face.mNormals[i] = n; + } + + if (i == 0) + { + min = max = p; + } + else + { + update_min_max(min, max, p); + } + } + + if (face.mExtents) + { + face.mExtents[0] = min; + face.mExtents[1] = max; + } + } + + // LLModelLoader::joint_lookup_func_t -- resolve a joint name against the + // agent's skeleton. (opaque is the LoadContext, unused here.) + LLJoint* lookupJoint(const std::string& name, void* /*opaque*/) + { + return isAgentAvatarValid() ? gAgentAvatarp->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; + } + } + + // LLModelLoader::load_callback_t -- runs on the main thread once parsing is + // done. Hands the scene to the unit, optionally spawns it, and reaps the + // loader (deferred, since we are inside the loader's own callback). + void onModelLoaded(LLModelLoader::scene& scene, LLModelLoader::model_list& /*models*/, S32 /*lod*/, void* opaque) + { + LoadContext* ctx = static_cast(opaque); + if (!ctx) + { + return; + } + + const LLUUID tracking_id = ctx->mTrackingID; + const U32 load_state = ctx->mLoadState; + + LLLocalMeshMgr* mgr = LLLocalMeshMgr::instanceExists() ? LLLocalMeshMgr::getInstance() : nullptr; + LLLocalMesh* unit = mgr ? mgr->getUnit(tracking_id) : nullptr; + if (unit) // null if the unit was removed while loading + { + if (load_state < LLModelLoader::ERROR_PARSING && !scene.empty()) + { + unit->onLoadComplete(scene); + } + else + { + LL_WARNS("LocalMesh") << "Parse failed (state " << load_state << ") for " << unit->getFilename() << LL_ENDL; + unit->markFailed(); + } + + if (unit->getValid()) + { + if (unit->wantsSpawn()) + { + mgr->spawnInWorld(tracking_id); + } + } + else + { + mgr->delUnit(tracking_id); // drop the failed unit + } + } + + // 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) + : mFilename(filename) + , mShortName(gDirUtilp->getBaseFileName(filename, true)) + , mFormat(FMT_NONE) + , mState(ST_LOADING) + , mSpawnWhenReady(false) + , mLastModified() + , mNumFaces(0) + , mNumVertices(0) + , mNumTriangles(0) + , mNumJoints(0) + , mTruncated(false) +{ + mTrackingID.generate(); + mWorldID.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() +{ + LoadContext* ctx = new LoadContext(); + ctx->mTrackingID = mTrackingID; + + JointMap joint_alias_map; + buildJointAliases(joint_alias_map); + + 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; + gAgentAvatarp->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 +} + +void LLLocalMesh::onLoadComplete(LLModelLoader::scene& scene) +{ + assembleFromScene(scene); + + mState = (mVolume.notNull() && mNumFaces > 0) ? ST_LOADED : ST_FAILED; + + if (mState == ST_LOADED) + { + mLastModified = std::filesystem::last_write_time(fsyspath(mFilename)); + LL_INFOS("LocalMesh") << "Loaded local mesh '" << mShortName << "' [" << mWorldID << "]: " + << mNumFaces << " faces, " << mNumVertices << " verts, " << mNumTriangles << " tris, " + << (isRigged() ? llformat("rigged (%d joints)", mNumJoints) : std::string("static")) + << (mTruncated ? " [TRUNCATED to MAX_MODEL_FACES]" : "") + << LL_ENDL; + } +} + +void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) +{ + std::vector faces; + const LLMeshSkinInfo* skin_src = nullptr; + + for (LLModelLoader::scene::iterator iter = scene.begin(); iter != scene.end() && !mTruncated; ++iter) + { + LLMatrix4a mat; + mat.loadu(iter->first); + + for (LLModelInstance& instance : iter->second) + { + LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); + if (!mdl) + { + continue; + } + + // Capture skin from the first rigged model encountered. + if (!skin_src && !mdl->mSkinInfo.mJointNames.empty()) + { + skin_src = &mdl->mSkinInfo; + } + + for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) + { + if ((S32)faces.size() >= MAX_MODEL_FACES) + { + // Faithful to the upload limit; warn and clip the remainder. + mTruncated = true; + break; + } + + LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy + transformFace(face, mat); + + mNumVertices += face.mNumVertices; + mNumTriangles += face.mNumIndices / 3; + faces.push_back(face); + } + + if (mTruncated) + { + break; + } + } + } + + if (faces.empty()) + { + LL_WARNS("LocalMesh") << "Local mesh produced no geometry: " << mFilename << LL_ENDL; + return; + } + + LLVolumeParams params; + params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + mVolume = new LLVolume(params, 1.f); + mVolume->copyFacesFrom(faces); + mVolume->setMeshAssetLoaded(true); + + if (skin_src) + { + // Round-trip through LLSD to get a clean, owned copy bound to our UUID. + LLSD sd = skin_src->asLLSD(true, skin_src->mLockScaleIfJointPosition); + mSkinInfo = new LLMeshSkinInfo(mWorldID, sd); + mNumJoints = (S32)skin_src->mJointNames.size(); + } + + mNumFaces = (S32)faces.size(); + + if (mTruncated) + { + LL_WARNS("LocalMesh") << "Local mesh '" << mShortName << "' exceeds " << MAX_MODEL_FACES + << " faces; extra faces were dropped from the preview." << LL_ENDL; + } +} + +/*=======================================*/ +/* LLLocalMeshMgr: manager class */ +/*=======================================*/ +LLLocalMeshMgr::LLLocalMeshMgr() +{ +} + +LLLocalMeshMgr::~LLLocalMeshMgr() +{ + for (LLLocalMesh* unit : mMeshList) + { + delete unit; + } + mMeshList.clear(); +} + +LLUUID LLLocalMeshMgr::addUnit(const std::string& filename) +{ + return addUnitInternal(filename); +} + +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) +{ + LLLocalMesh* unit = new LLLocalMesh(filename); + 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); + 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) + { + despawnForWorldID(unit->getWorldID()); + iter = mMeshList.erase(iter); + delete unit; + } + else + { + ++iter; + } + } +} + +LLUUID LLLocalMeshMgr::getUnitID(const std::string& filename) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getFilename() == filename) + { + return unit->getTrackingID(); + } + } + return LLUUID::null; +} + +LLUUID LLLocalMeshMgr::getTrackingID(const LLUUID& world_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getWorldID() == world_id) + { + return unit->getTrackingID(); + } + } + return LLUUID::null; +} + +LLUUID LLLocalMeshMgr::getWorldID(const LLUUID& tracking_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getTrackingID() == tracking_id) + { + return unit->getWorldID(); + } + } + return LLUUID::null; +} + +bool LLLocalMeshMgr::isLocal(const LLUUID& world_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getWorldID() == world_id) + { + return true; + } + } + return false; +} + +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; +} + +LLLocalMesh* LLLocalMeshMgr::getUnitByWorldID(const LLUUID& world_id) const +{ + for (LLLocalMesh* unit : mMeshList) + { + if (unit->getWorldID() == world_id) + { + return unit; + } + } + return nullptr; +} + +LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid() || !unit->getVolume()) + { + 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; + } + + LLViewerObject* obj = gObjectList.createObjectViewer(LL_PCODE_VOLUME, gAgent.getRegion()); + LLVOVolume* vol = dynamic_cast(obj); + if (!vol) + { + LL_WARNS("LocalMesh") << "spawnInWorld: failed to create volume object" << LL_ENDL; + if (obj) + { + obj->markDead(); + } + return nullptr; + } + + // Not selectable yet: until the LLSelectMgr server-I/O gating is in place, + // editing this client-only object would send updates for an object the sim + // doesn't know about. Selection/manipulation is enabled in a later step. + vol->mbCanSelect = false; + + // Build the drawable, then force a valid LOD. LLDrawable's constructor calls + // setNoLOD(); with mLOD == NO_LOD, LLPrimitive::setVolume builds a + // placeholder cube and returns false, so LLVOVolume::setVolume skips + // loadMesh entirely (giving a grey cube and a face-count churn that + // corrupts the heap). A real LOD makes the repository injection serve our + // decoded geometry directly, with the right face count from the start. + gPipeline.createObject(obj); + vol->setLOD(LLVolumeLODGroup::NUM_LODS - 1); + + // Place a few meters in front of the agent at native scale. + const LLVector3 pos = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f; + vol->setPositionAgent(pos); + vol->setScale(LLVector3(1.f, 1.f, 1.f), false); + + // isSculpted()/isMesh() key off the PARAMS_SCULPT extra param (not the + // volume params alone). Without it, LLVOVolume::setVolume skips the entire + // mesh-load block and the object stays a plain prim. local_origin=false so + // nothing is sent to the sim for this client-only object. + LLSculptParams sculpt_params; + sculpt_params.setSculptTexture(unit->getWorldID(), LL_SCULPT_TYPE_MESH); + vol->setParameterEntry(LLNetworkData::PARAMS_SCULPT, sculpt_params, false); + + // Reference the local mesh by its world UUID; the repository injection + // resolves it to the decoded geometry. + LLVolumeParams params; + params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + params.setSculptID(unit->getWorldID(), LL_SCULPT_TYPE_MESH); + vol->setVolume(params, LLVolumeLODGroup::NUM_LODS - 1); + + // Give every face a visible default texture (file materials are not yet + // applied in this milestone). + LLVolume* v = vol->getVolume(); + const S32 num_faces = v ? v->getNumVolumeFaces() : 0; + if (num_faces > 0) + { + vol->setNumTEs((U8)num_faces); + for (S32 i = 0; i < num_faces; ++i) + { + vol->setTETexture((U8)i, IMG_DEFAULT); + } + } + + vol->markForUpdate(); + + mSpawnedObjects.emplace_back(unit->getWorldID(), obj); + + LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' (" << num_faces + << " faces) at " << pos << LL_ENDL; + return obj; +} + +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::despawnForWorldID(const LLUUID& world_id) +{ + for (auto iter = mSpawnedObjects.begin(); iter != mSpawnedObjects.end(); ) + { + if (iter->first == world_id) + { + if (iter->second.notNull() && !iter->second->isDead()) + { + iter->second->markDead(); + } + iter = mSpawnedObjects.erase(iter); + } + else + { + ++iter; + } + } +} + +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) + { + 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(); + + LLSD data; + data["id"] = unit->getTrackingID(); + data["type"] = (S32)LLAssetType::AT_OBJECT; + element["value"] = data; + + ctrl->addElement(element); + } +} diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h new file mode 100644 index 0000000000..a8b25d7c40 --- /dev/null +++ b/indra/newview/lllocalmesh.h @@ -0,0 +1,156 @@ +/** + * @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 "llpointer.h" +#include "llsingleton.h" +#include "lluuid.h" + +#include +#include +#include +#include +#include + +class LLScrollListCtrl; +class LLViewerObject; +class LLVolume; + +// A single local mesh file and its decoded, in-memory representation. +class LLLocalMesh +{ +public: + LLLocalMesh(std::string filename); + ~LLLocalMesh(); + + std::string getFilename() const { return mFilename; } + std::string getShortName() const { return mShortName; } + LLUUID getTrackingID() const { return mTrackingID; } + LLUUID getWorldID() const { return mWorldID; } + + bool getValid() const { return mState == ST_LOADED; } + bool isLoading() const { return mState == ST_LOADING; } + bool isFailed() const { return mState == ST_FAILED; } + + // Decoded geometry/skin -- consumed by the mesh repository injection. + LLVolume* getVolume() const { return mVolume; } + const LLMeshSkinInfo* getSkinInfo() const { return mSkinInfo; } + bool isRigged() const { return mSkinInfo.notNull(); } + + // Stats (for UI + logging). + 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; } + + // Main-thread completion hooks driven by the load callback. + void onLoadComplete(LLModelLoader::scene& scene); + void markFailed() { mState = ST_FAILED; } + +private: + enum EFormat { FMT_NONE, FMT_DAE, FMT_GLTF }; + enum EState { ST_LOADING, ST_LOADED, ST_FAILED }; + + void startLoad(); + void assembleFromScene(LLModelLoader::scene& scene); + + std::string mFilename; + std::string mShortName; + LLUUID mTrackingID; // stable, identifies this unit in UI + LLUUID mWorldID; // stable mesh UUID objects reference (kept across reloads) + EFormat mFormat; + EState mState; + bool mSpawnWhenReady; + + std::filesystem::file_time_type mLastModified; // for live reload (M3) + + LLPointer mVolume; // assembled high-LOD geometry, served for all LODs + LLPointer mSkinInfo; // null if not rigged + + S32 mNumFaces; + S32 mNumVertices; + S32 mNumTriangles; + S32 mNumJoints; + bool mTruncated; // geometry exceeded MAX_MODEL_FACES and was clipped +}; + +// 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 addUnit(const std::vector& filenames); + void delUnit(LLUUID tracking_id); + + LLUUID getUnitID(const std::string& filename) const; + LLUUID getTrackingID(const LLUUID& world_id) const; + LLUUID getWorldID(const LLUUID& tracking_id) const; + bool isLocal(const LLUUID& world_id) const; + std::string getFilename(const LLUUID& tracking_id) const; + + LLLocalMesh* getUnit(const LLUUID& tracking_id) const; + LLLocalMesh* getUnitByWorldID(const LLUUID& world_id) const; + + // Create a client-only LLVOVolume in-world referencing the unit's mesh. + 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); + + void feedScrollList(LLScrollListCtrl* ctrl); + +private: + LLUUID addUnitInternal(const std::string& filename); + void despawnForWorldID(const LLUUID& world_id); + + typedef std::list::iterator local_list_iter; + typedef std::list::const_iterator local_list_citer; + std::list mMeshList; + + // Client-only objects we've rezzed, paired with the world ID they show. + std::vector > > mSpawnedObjects; +}; + +#endif // LL_LLLOCALMESH_H diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index e301da4760..5d2c8825a1 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(); @@ -4381,6 +4387,31 @@ 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. + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_params.getSculptID()); + if (unit && unit->getVolume()) + { + LLVolume* sys_volume = LLPrimitive::getVolumeManager()->refVolume(mesh_params, new_lod); + if (sys_volume) + { + if (!sys_volume->isMeshAssetLoaded()) + { + sys_volume->copyVolumeFaces(unit->getVolume()); + sys_volume->setMeshAssetLoaded(true); + } + LLPrimitive::getVolumeManager()->unrefVolume(sys_volume); + } + if (vobj) + { + vobj->notifyMeshLoaded(); + } + return new_lod; + } + } + { LLMutexLock lock(mMeshMutex); //add volume to list of loading meshes @@ -4864,12 +4895,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()) + { + LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_id); + if (unit) + { + return unit->getSkinInfo(); + } + } + if (mesh_id.notNull()) { skin_map::iterator iter = mSkinMap.find(mesh_id); @@ -5008,6 +5055,15 @@ bool LLMeshRepository::hasSkinInfo(const LLUUID& mesh_id) return false; } + if (LLLocalMeshMgr::instanceExists()) + { + LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_id); + if (unit) + { + return unit->isRigged(); + } + } + if (mThread->hasSkinInfoInHeader(mesh_id)) { return true; @@ -5029,6 +5085,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/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 152377de32..1f461addd3 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -120,6 +120,7 @@ #include "llviewergenericmessage.h" #include "llviewerhelp.h" #include "llviewermenufile.h" // init_menu_file() +#include "lllocalmesh.h" #include "llviewermessage.h" #include "llviewernetwork.h" #include "llviewerobjectlist.h" @@ -663,6 +664,27 @@ 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; + } +}; + class LLAdvancedToggleConsole : public view_listener_t { bool handleEvent(const LLSD& userdata) @@ -10411,6 +10433,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"); 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/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml index bd7597bf04..2f2863ede1 100644 --- a/indra/newview/skins/default/xui/en/menu_viewer.xml +++ b/indra/newview/skins/default/xui/en/menu_viewer.xml @@ -3042,6 +3042,12 @@ function="World.EnvPreset" name="Develop" tear_off="true" visible="false"> + + + Date: Tue, 2 Jun 2026 23:28:14 -0400 Subject: [PATCH 02/58] Local mesh preview: make the in-world object selectable and movable (M2c) The client-only preview can now be selected and moved/rotated/scaled with the standard build tools, with zero server traffic. - spawnInWorld marks it selectable (mbCanSelect) and sets full owner permission flags (FLAGS_OBJECT_YOU_OWNER/MODIFY/MOVE/COPY/TRANSFER) so permYouOwner/permModify/permMove enable manipulation. - LLLocalMeshMgr::isLocalPreview(obj) identifies spawned preview objects. - LLSelectMgr suppresses all server I/O for them: ObjectSelect (direct and sendSelect), ObjectDeselect (direct and deselectAll/ForStandingUp), RequestObjectPropertiesFamily, and MultipleObjectUpdate (transform sends). Sends are only skipped when the selection is entirely previews, so mixed selections still send for real objects. - addAsIndividual synthesizes a valid, agent-owned LLSelectNode for previews (no sim ObjectProperties reply arrives) so the build floater treats them as editable. The manip tools apply transforms locally during drag as before; with the commit sends suppressed and no server echo, the local transform sticks. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 27 +++++++-- indra/newview/lllocalmesh.h | 4 ++ indra/newview/llselectmgr.cpp | 109 ++++++++++++++++++++++++++-------- 3 files changed, 112 insertions(+), 28 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 6ac43dd314..7562689c8f 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -44,6 +44,7 @@ #include "llcallbacklist.h" // doOnIdleOneTime #include "llinventoryicon.h" #include "llprimitive.h" // LL_PCODE_VOLUME +#include "object_flags.h" // FLAGS_OBJECT_* for owner permissions #include "llscrolllistctrl.h" #include "llviewercontrol.h" #include "llviewerobjectlist.h" @@ -569,6 +570,22 @@ LLLocalMesh* LLLocalMeshMgr::getUnitByWorldID(const LLUUID& world_id) const return nullptr; } +bool LLLocalMeshMgr::isLocalPreview(const LLViewerObject* obj) const +{ + if (!obj) + { + return false; + } + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == obj) + { + return true; + } + } + return false; +} + LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) { LLLocalMesh* unit = getUnit(tracking_id); @@ -596,10 +613,12 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) return nullptr; } - // Not selectable yet: until the LLSelectMgr server-I/O gating is in place, - // editing this client-only object would send updates for an object the sim - // doesn't know about. Selection/manipulation is enabled in a later step. - vol->mbCanSelect = false; + // Selectable and movable with the standard build tools. LLSelectMgr + // suppresses all server traffic for these client-only objects (gated on + // isLocalPreview), and we set full owner permission flags so the tools + // enable manipulation (permYouOwner/permModify/permMove read these flags). + vol->mbCanSelect = true; + vol->setFlagsWithoutUpdate(FLAGS_OBJECT_YOU_OWNER | FLAGS_OBJECT_MODIFY | FLAGS_OBJECT_MOVE | FLAGS_OBJECT_COPY | FLAGS_OBJECT_TRANSFER, true); // Build the drawable, then force a valid LOD. LLDrawable's constructor calls // setNoLOD(); with mLOD == NO_LOD, LLPrimitive::setVolume builds a diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index a8b25d7c40..ea9a879089 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -134,6 +134,10 @@ class LLLocalMeshMgr : public LLSingleton LLLocalMesh* getUnit(const LLUUID& tracking_id) const; LLLocalMesh* getUnitByWorldID(const LLUUID& world_id) const; + // True if the object is one of our client-only in-world preview spawns. + // Used by LLSelectMgr to suppress all server traffic for these objects. + bool isLocalPreview(const LLViewerObject* obj) const; + // Create a client-only LLVOVolume in-world referencing the unit's mesh. LLViewerObject* spawnInWorld(const LLUUID& tracking_id); // Convenience: load each file and spawn it in-world once it finishes loading. diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 52bda378d5..641d4d06e1 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -101,8 +101,36 @@ // [/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) +{ + return obj && LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocalPreview(obj); +} + +// 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; +} // // Consts // @@ -490,15 +518,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(); @@ -972,7 +1003,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); @@ -1061,6 +1092,18 @@ void LLSelectMgr::addAsIndividual(LLViewerObject *objectp, S32 face, bool undoab nodep = new LLSelectNode(objectp, true); mSelectedObjects->addNode(nodep); llassert_always(nodep->getObject()); + + if (isLocalPreviewObject(objectp)) + { + // Client-only preview: no sim ObjectProperties reply will ever + // arrive, so synthesize a valid, fully agent-owned node here so the + // build tools consider it editable. + nodep->mValid = true; + nodep->mName = "(local mesh preview)"; + 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); + } } else { @@ -4744,6 +4787,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 +4997,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 +5031,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 +5407,8 @@ void LLSelectMgr::sendSelect() return; } + if (selectionAllLocalPreview(mSelectedObjects)) return; + sendListToRegions( "ObjectSelect", packAgentAndSessionID, @@ -5970,6 +6024,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); From c6de6a5ebab3ec919abc91decd896ddabfd79d5e Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 2 Jun 2026 23:42:28 -0400 Subject: [PATCH 03/58] Local mesh preview: generate tangents so picking doesn't crash (M2 fix) Right-clicking/hovering the preview crashed in LLVolume::genTangents during raycast: loaded mesh assets are required to carry tangents, but the assembled volume (copied from the GLTF loader's LLModel, which has none) was flagged setMeshAssetLoaded(true) without them, so picking dereferenced absent tangents. Run cacheOptimize(true) after assembling the faces -- the same call unpackVolumeFaces() makes for a real mesh asset -- to optimize the index buffer and generate tangents before marking the asset loaded. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 7562689c8f..888fa7cb49 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -398,6 +398,15 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); mVolume = new LLVolume(params, 1.f); mVolume->copyFacesFrom(faces); + + // Optimize the index buffer and generate tangents, matching what + // unpackVolumeFaces() does for a real mesh asset. Loaded mesh assets are + // required to carry tangents (see LLVolume::genTangents) and raycast + // picking dereferences them, so this must run before setMeshAssetLoaded(). + if (!mVolume->cacheOptimize(true)) + { + LL_WARNS("LocalMesh") << "cacheOptimize failed for '" << mShortName << "'" << LL_ENDL; + } mVolume->setMeshAssetLoaded(true); if (skin_src) From fbff0f39ee20e1671a8629f15ca2a499e65ec255 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 2 Jun 2026 23:50:27 -0400 Subject: [PATCH 04/58] Local mesh preview: populate the build floater for selected previews (M2c fix) Selecting a preview left the build floater blank. The build-tool select path (selectObjectAndFamily -> addAsFamily) creates select nodes without the synthesis I'd added in addAsIndividual, and no sim ObjectProperties reply ever arrives to set node->mValid, so the floater's panels treated the selection as invalid and showed nothing. Add a shared synthesizeLocalPreviewNode() that fills in a valid, agent-owned, fully-permissive node (as processObjectProperties would) and call it from both addAsFamily and addAsIndividual. selectObjectAndFamily already calls dialog_refresh_all() afterward, so the floater repopulates. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/llselectmgr.cpp | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 641d4d06e1..875bd278c1 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -131,6 +131,23 @@ static bool selectionAllLocalPreview(LLObjectSelectionHandle selection) } 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; + nodep->mName = "(local mesh preview)"; + nodep->mDescription.clear(); + 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); +} // // Consts // @@ -1044,6 +1061,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); @@ -1092,18 +1110,7 @@ void LLSelectMgr::addAsIndividual(LLViewerObject *objectp, S32 face, bool undoab nodep = new LLSelectNode(objectp, true); mSelectedObjects->addNode(nodep); llassert_always(nodep->getObject()); - - if (isLocalPreviewObject(objectp)) - { - // Client-only preview: no sim ObjectProperties reply will ever - // arrive, so synthesize a valid, fully agent-owned node here so the - // build tools consider it editable. - nodep->mValid = true; - nodep->mName = "(local mesh preview)"; - 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); - } + synthesizeLocalPreviewNode(nodep, objectp); } else { From f0cf31a56ba31e2f34d09f9767d4cd795a7fe486 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 00:05:26 -0400 Subject: [PATCH 05/58] Local mesh preview: normalize merged geometry so bounds/pivot are correct (M2c fix) Both the DAE and glTF loaders normalize each model into a unit box and record its size, the same convention the upload path relies on. assembleFromScene then bakes the per-instance node transforms while merging faces, which puts the merged result back into authored world space -- for the Khronos Duck that is ~100 units and well off-origin. The spawned object was placed with a 1x1x1 prim scale, so the pivot/gizmo sat away from the geometry, the selection box did not match, and the build-tool size read 1m. Re-normalize the merged faces into a unit box centred at the origin (mirroring LLModel::normalizeVolumeFaces: positions, normals, extents, texcoord extents) and keep the recovered size as the prim scale. The preview now centres on its pivot and reports its authored dimensions. Rigged meshes are left in skin/bind space -- the skeleton, not the object transform, drives them (handled at rigged-attach). Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 101 +++++++++++++++++++++++++++++++++- indra/newview/lllocalmesh.h | 6 ++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 888fa7cb49..8819c027fc 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -138,6 +138,87 @@ namespace } } + // Normalize a merged face set into a unit box centred at the origin and + // return its authored size, mirroring LLModel::normalizeVolumeFaces() (the + // upload path's convention). The returned size becomes the spawned object's + // prim scale, so the preview renders at native dimensions, is centred on its + // pivot, and reports correct extents to the build tools. Tangents are + // intentionally not touched here -- cacheOptimize() generates them + // afterwards on the already-normalized geometry. + LLVector3 normalizeFaces(std::vector& faces) + { + if (faces.empty()) + { + return LLVector3(1.f, 1.f, 1.f); + } + + LLVector4a min = faces[0].mExtents[0]; + LLVector4a max = faces[0].mExtents[1]; + for (size_t i = 1; i < faces.size(); ++i) + { + update_min_max(min, max, faces[i].mExtents[0]); + update_min_max(min, max, faces[i].mExtents[1]); + } + + // Translation that centres the geometry at the origin. + LLVector4a trans; + trans.setAdd(min, max); + trans.mul(-0.5f); + + // Size along each axis (guard zero-thickness axes against div-by-zero). + LLVector4a size; + size.setSub(max, min); + F32 sx = size[0], sy = size[1], sz = size[2], sw = size[3]; + if (fabs(sx) < F_APPROXIMATELY_ZERO) sx = 1.f; + if (fabs(sy) < F_APPROXIMATELY_ZERO) sy = 1.f; + if (fabs(sz) < F_APPROXIMATELY_ZERO) sz = 1.f; + size.set(sx, sy, sz, sw); + + LLVector4a scale; + scale.splat(1.f); + scale.div(size); // 1 / size + LLVector4a inv_scale(1.f); + inv_scale.div(scale); // == size + + for (LLVolumeFace& face : faces) + { + // Shrink extents into the unit cube. + face.mExtents[0].add(trans); + face.mExtents[0].mul(scale); + face.mExtents[1].add(trans); + face.mExtents[1].mul(scale); + + for (S32 j = 0; j < face.mNumVertices; ++j) + { + face.mPositions[j].add(trans); + face.mPositions[j].mul(scale); + if (face.mNormals && !face.mNormals[j].equals3(LLVector4a::getZero())) + { + face.mNormals[j].mul(inv_scale); + face.mNormals[j].normalize3(); + } + } + + // Texture-coordinate extents (rendering and tangent generation use these). + if (face.mTexCoords) + { + face.mTexCoordExtents[0] = face.mTexCoords[0]; + face.mTexCoordExtents[1] = face.mTexCoords[0]; + for (S32 j = 1; j < face.mNumVertices; ++j) + { + update_min_max(face.mTexCoordExtents[0], face.mTexCoordExtents[1], face.mTexCoords[j]); + } + } + else + { + face.mTexCoordExtents[0].set(0.f, 0.f); + face.mTexCoordExtents[1].set(1.f, 1.f); + } + } + + return LLVector3(size[0], size[1], size[2]); + } + // LLModelLoader::joint_lookup_func_t -- resolve a joint name against the // agent's skeleton. (opaque is the LoadContext, unused here.) LLJoint* lookupJoint(const std::string& name, void* /*opaque*/) @@ -219,6 +300,7 @@ LLLocalMesh::LLLocalMesh(std::string filename) , mNumVertices(0) , mNumTriangles(0) , mNumJoints(0) + , mScale(1.f, 1.f, 1.f) , mTruncated(false) { mTrackingID.generate(); @@ -334,6 +416,7 @@ void LLLocalMesh::onLoadComplete(LLModelLoader::scene& scene) mLastModified = std::filesystem::last_write_time(fsyspath(mFilename)); LL_INFOS("LocalMesh") << "Loaded local mesh '" << mShortName << "' [" << mWorldID << "]: " << mNumFaces << " faces, " << mNumVertices << " verts, " << mNumTriangles << " tris, " + << "size " << mScale << ", " << (isRigged() ? llformat("rigged (%d joints)", mNumJoints) : std::string("static")) << (mTruncated ? " [TRUNCATED to MAX_MODEL_FACES]" : "") << LL_ENDL; @@ -394,6 +477,18 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) return; } + // Static meshes: re-normalize the merged geometry into a unit box centred at + // the origin and keep the authored size for the prim scale. Both loaders + // normalize per-model, but baking the instance transforms above puts the + // merged result back into authored world space (often large and off-origin), + // so the object pivot, bounding box and build-tool size would all be wrong + // without this. Rigged meshes stay in skin/bind space -- the skeleton, not + // the object transform, drives their placement (handled at rigged-attach). + if (!skin_src) + { + mScale = normalizeFaces(faces); + } + LLVolumeParams params; params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); mVolume = new LLVolume(params, 1.f); @@ -638,10 +733,12 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) gPipeline.createObject(obj); vol->setLOD(LLVolumeLODGroup::NUM_LODS - 1); - // Place a few meters in front of the agent at native scale. + // Place a few meters in front of the agent. The geometry was normalized to a + // unit box centred on the origin, so the object's prim scale carries the + // authored size and the pivot sits at the geometry's centre. const LLVector3 pos = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f; vol->setPositionAgent(pos); - vol->setScale(LLVector3(1.f, 1.f, 1.f), false); + vol->setScale(unit->getScale(), false); // isSculpted()/isMesh() key off the PARAMS_SCULPT extra param (not the // volume params alone). Without it, LLVOVolume::setVolume skips the entire diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index ea9a879089..955432e869 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -40,6 +40,7 @@ #include "llpointer.h" #include "llsingleton.h" #include "lluuid.h" +#include "v3math.h" #include #include @@ -72,6 +73,10 @@ class LLLocalMesh const LLMeshSkinInfo* getSkinInfo() const { return mSkinInfo; } bool isRigged() const { return mSkinInfo.notNull(); } + // Authored bounding-box size; the spawned static preview uses this as its + // prim scale. (1,1,1) when rigged (the rig, not the object scale, governs). + LLVector3 getScale() const { return mScale; } + // Stats (for UI + logging). S32 getNumFaces() const { return mNumFaces; } S32 getNumVertices() const { return mNumVertices; } @@ -105,6 +110,7 @@ class LLLocalMesh LLPointer mVolume; // assembled high-LOD geometry, served for all LODs LLPointer mSkinInfo; // null if not rigged + LLVector3 mScale; // authored size; prim scale for the static preview ((1,1,1) when rigged) S32 mNumFaces; S32 mNumVertices; From 089bdfb3603b24da10c6bb908b5f603bfbd7c214 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 00:26:18 -0400 Subject: [PATCH 06/58] Local mesh preview: live reload on source-file change (M3) A 3s LLEventTimer (mirroring LLLocalBitmapTimer) polls each loaded unit's source file mtime. On a change it kicks the same async re-parse used for the initial load; the geometry swap happens back in the load callback. To swap under a stable reference without fighting the volume cache (loadMesh's injection is guarded on !isMeshAssetLoaded(), so reusing the world id would be a no-op), the unit mints a fresh world id on a successful reload and re-points its spawned objects at it -- setVolume sees the changed sculpt id, drops the stale skin, and re-runs the mesh path against the new geometry. The old volume is unref'd, so there is no per-reload cache growth. This is the same new-id approach local bitmaps use (replaceIDs). Robustness: - ingestScene() assembles into locals and commits only on success, so a failed reload (corrupt/locked file, empty scene) keeps showing the last good mesh. - the parsed mtime is captured at kickoff and stamped on success; a version that fails to parse is remembered and not retried until the file changes again. - reload preserves the object's transform (position/rotation/scale) -- only the mesh data updates, like a local-bitmap texture swap. Face-count changes are handled (TEs are re-derived from the new volume). - the timer runs only while at least one unit is loaded. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 313 ++++++++++++++++++++++++++-------- indra/newview/lllocalmesh.h | 53 +++++- 2 files changed, 290 insertions(+), 76 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 8819c027fc..f70a2c0954 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -235,9 +235,20 @@ namespace } } + // 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->getWorldID() << "]: " + << unit->getNumFaces() << " faces, " << unit->getNumVertices() << " verts, " + << unit->getNumTriangles() << " tris, size " << unit->getScale() << ", " + << (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 scene to the unit, optionally spawns it, and reaps the - // loader (deferred, since we are inside the loader's own callback). + // 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); @@ -246,34 +257,9 @@ namespace return; } - const LLUUID tracking_id = ctx->mTrackingID; - const U32 load_state = ctx->mLoadState; - - LLLocalMeshMgr* mgr = LLLocalMeshMgr::instanceExists() ? LLLocalMeshMgr::getInstance() : nullptr; - LLLocalMesh* unit = mgr ? mgr->getUnit(tracking_id) : nullptr; - if (unit) // null if the unit was removed while loading + if (LLLocalMeshMgr* mgr = LLLocalMeshMgr::instanceExists() ? LLLocalMeshMgr::getInstance() : nullptr) { - if (load_state < LLModelLoader::ERROR_PARSING && !scene.empty()) - { - unit->onLoadComplete(scene); - } - else - { - LL_WARNS("LocalMesh") << "Parse failed (state " << load_state << ") for " << unit->getFilename() << LL_ENDL; - unit->markFailed(); - } - - if (unit->getValid()) - { - if (unit->wantsSpawn()) - { - mgr->spawnInWorld(tracking_id); - } - } - else - { - mgr->delUnit(tracking_id); // drop the failed unit - } + mgr->onLoadResult(ctx->mTrackingID, scene, ctx->mLoadState); } // The model loader deletes itself in LLModelLoader::loadModelCallback @@ -302,6 +288,9 @@ LLLocalMesh::LLLocalMesh(std::string filename) , mNumJoints(0) , mScale(1.f, 1.f, 1.f) , mTruncated(false) + , mReloading(false) + , mPendingModified() + , mFailedModified() { mTrackingID.generate(); mWorldID.generate(); @@ -351,6 +340,12 @@ LLLocalMesh::~LLLocalMesh() 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; @@ -405,30 +400,17 @@ void LLLocalMesh::startLoad() ctx->mLoader->start(); // parse on the worker thread; onModelLoaded fires on the main thread, then the loader self-deletes } -void LLLocalMesh::onLoadComplete(LLModelLoader::scene& scene) -{ - assembleFromScene(scene); - - mState = (mVolume.notNull() && mNumFaces > 0) ? ST_LOADED : ST_FAILED; - - if (mState == ST_LOADED) - { - mLastModified = std::filesystem::last_write_time(fsyspath(mFilename)); - LL_INFOS("LocalMesh") << "Loaded local mesh '" << mShortName << "' [" << mWorldID << "]: " - << mNumFaces << " faces, " << mNumVertices << " verts, " << mNumTriangles << " tris, " - << "size " << mScale << ", " - << (isRigged() ? llformat("rigged (%d joints)", mNumJoints) : std::string("static")) - << (mTruncated ? " [TRUNCATED to MAX_MODEL_FACES]" : "") - << LL_ENDL; - } -} - -void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) +bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) { + // Assemble into locals first; members are only overwritten once we know the + // parse produced geometry, so a failed reload preserves the last good mesh. std::vector faces; const LLMeshSkinInfo* skin_src = nullptr; + S32 num_vertices = 0; + S32 num_triangles = 0; + bool truncated = false; - for (LLModelLoader::scene::iterator iter = scene.begin(); iter != scene.end() && !mTruncated; ++iter) + for (LLModelLoader::scene::iterator iter = scene.begin(); iter != scene.end() && !truncated; ++iter) { LLMatrix4a mat; mat.loadu(iter->first); @@ -452,19 +434,19 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) if ((S32)faces.size() >= MAX_MODEL_FACES) { // Faithful to the upload limit; warn and clip the remainder. - mTruncated = true; + truncated = true; break; } LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy transformFace(face, mat); - mNumVertices += face.mNumVertices; - mNumTriangles += face.mNumIndices / 3; + num_vertices += face.mNumVertices; + num_triangles += face.mNumIndices / 3; faces.push_back(face); } - if (mTruncated) + if (truncated) { break; } @@ -474,7 +456,7 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) if (faces.empty()) { LL_WARNS("LocalMesh") << "Local mesh produced no geometry: " << mFilename << LL_ENDL; - return; + return false; // keep any previously loaded geometry intact } // Static meshes: re-normalize the merged geometry into a unit box centred at @@ -484,41 +466,107 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) // so the object pivot, bounding box and build-tool size would all be wrong // without this. Rigged meshes stay in skin/bind space -- the skeleton, not // the object transform, drives their placement (handled at rigged-attach). + LLVector3 scale(1.f, 1.f, 1.f); if (!skin_src) { - mScale = normalizeFaces(faces); + scale = normalizeFaces(faces); } LLVolumeParams params; params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); - mVolume = new LLVolume(params, 1.f); - mVolume->copyFacesFrom(faces); + LLPointer volume = new LLVolume(params, 1.f); + volume->copyFacesFrom(faces); // Optimize the index buffer and generate tangents, matching what // unpackVolumeFaces() does for a real mesh asset. Loaded mesh assets are // required to carry tangents (see LLVolume::genTangents) and raycast // picking dereferences them, so this must run before setMeshAssetLoaded(). - if (!mVolume->cacheOptimize(true)) + if (!volume->cacheOptimize(true)) { LL_WARNS("LocalMesh") << "cacheOptimize failed for '" << mShortName << "'" << LL_ENDL; } - mVolume->setMeshAssetLoaded(true); + volume->setMeshAssetLoaded(true); + LLPointer skin; + S32 num_joints = 0; if (skin_src) { // Round-trip through LLSD to get a clean, owned copy bound to our UUID. LLSD sd = skin_src->asLLSD(true, skin_src->mLockScaleIfJointPosition); - mSkinInfo = new LLMeshSkinInfo(mWorldID, sd); - mNumJoints = (S32)skin_src->mJointNames.size(); + skin = new LLMeshSkinInfo(mWorldID, sd); + num_joints = (S32)skin_src->mJointNames.size(); } - mNumFaces = (S32)faces.size(); + // Commit. + mVolume = volume; + mSkinInfo = skin; // null clears any prior rig + mScale = scale; + mNumFaces = (S32)faces.size(); + mNumVertices = num_vertices; + mNumTriangles = num_triangles; + mNumJoints = num_joints; + mTruncated = truncated; + mState = ST_LOADED; + mLastModified = mPendingModified; // the exact version we just parsed - if (mTruncated) + if (truncated) { LL_WARNS("LocalMesh") << "Local mesh '" << mShortName << "' exceeds " << MAX_MODEL_FACES << " faces; extra faces were dropped from the preview." << LL_ENDL; } + + return true; +} + +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; + } +} + +void LLLocalMesh::regenerateWorldID() +{ + mWorldID.generate(); + if (mSkinInfo.notNull()) + { + mSkinInfo->mMeshID = mWorldID; // keep the skin's id in sync with the new world id + } } /*=======================================*/ @@ -526,6 +574,7 @@ void LLLocalMesh::assembleFromScene(LLModelLoader::scene& scene) /*=======================================*/ LLLocalMeshMgr::LLLocalMeshMgr() { + mTimer.stopTimer(); // started on demand once the first unit is added } LLLocalMeshMgr::~LLLocalMeshMgr() @@ -569,6 +618,10 @@ LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename) // Loading (async) or already loaded -- keep it; completion is handled in // the load callback. mMeshList.push_back(unit); + if (!mTimer.isRunning()) + { + mTimer.startTimer(); // begin watching source files for live reload + } return unit->getTrackingID(); } @@ -588,6 +641,11 @@ void LLLocalMeshMgr::delUnit(LLUUID tracking_id) ++iter; } } + + if (mMeshList.empty()) + { + mTimer.stopTimer(); // nothing left to watch + } } LLUUID LLLocalMeshMgr::getUnitID(const std::string& filename) const @@ -734,12 +792,25 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) vol->setLOD(LLVolumeLODGroup::NUM_LODS - 1); // Place a few meters in front of the agent. The geometry was normalized to a - // unit box centred on the origin, so the object's prim scale carries the - // authored size and the pivot sits at the geometry's centre. + // unit box centred on the origin, so the prim scale carries the authored size + // and the pivot sits at the geometry's centre. Scale/position are set here + // (spawn only); live reload preserves whatever transform the user has set. const LLVector3 pos = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f; vol->setPositionAgent(pos); vol->setScale(unit->getScale(), false); + applyUnitGeometry(vol, unit); + + mSpawnedObjects.emplace_back(unit->getWorldID(), obj); + + LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' (" + << (vol->getVolume() ? vol->getVolume()->getNumVolumeFaces() : 0) + << " faces) at " << pos << LL_ENDL; + return obj; +} + +void LLLocalMeshMgr::applyUnitGeometry(LLVOVolume* vol, const LLLocalMesh* unit) +{ // isSculpted()/isMesh() key off the PARAMS_SCULPT extra param (not the // volume params alone). Without it, LLVOVolume::setVolume skips the entire // mesh-load block and the object stays a plain prim. local_origin=false so @@ -749,14 +820,15 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) vol->setParameterEntry(LLNetworkData::PARAMS_SCULPT, sculpt_params, false); // Reference the local mesh by its world UUID; the repository injection - // resolves it to the decoded geometry. + // resolves it to the decoded geometry. On reload the world id has changed, + // so setVolume re-runs the mesh path and picks up the fresh geometry/skin. LLVolumeParams params; params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); params.setSculptID(unit->getWorldID(), LL_SCULPT_TYPE_MESH); vol->setVolume(params, LLVolumeLODGroup::NUM_LODS - 1); // Give every face a visible default texture (file materials are not yet - // applied in this milestone). + // applied in this milestone). The face count can change across reloads. LLVolume* v = vol->getVolume(); const S32 num_faces = v ? v->getNumVolumeFaces() : 0; if (num_faces > 0) @@ -769,12 +841,88 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) } vol->markForUpdate(); +} - mSpawnedObjects.emplace_back(unit->getWorldID(), obj); +void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scene& scene, U32 load_state) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit) // removed while loading + { + return; + } - LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' (" << num_faces - << " faces) at " << pos << LL_ENDL; - return obj; + 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) + { + // Swap geometry under a fresh world id and re-point our spawns at it. + const LLUUID old_world = unit->getWorldID(); + unit->regenerateWorldID(); + logUnit("Reloaded", unit); + repointSpawnedObjects(old_world, unit); + } + return; + } + + // Initial load. + if (assembled) + { + logUnit("Loaded", unit); + if (unit->wantsSpawn()) + { + spawnInWorld(tracking_id); + } + } + else + { + unit->markFailed(); + delUnit(tracking_id); // drop the failed unit + } +} + +void LLLocalMeshMgr::repointSpawnedObjects(const LLUUID& old_world_id, LLLocalMesh* unit) +{ + const LLUUID new_world_id = unit->getWorldID(); + for (auto& spawned : mSpawnedObjects) + { + if (spawned.first != old_world_id) + { + continue; + } + LLViewerObject* obj = spawned.second.get(); + if (obj && !obj->isDead()) + { + if (LLVOVolume* vol = dynamic_cast(obj)) + { + applyUnitGeometry(vol, unit); // keeps the object's transform + } + } + spawned.first = new_world_id; // track the new id for future reloads/despawn + } +} + +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) @@ -843,3 +991,26 @@ void LLLocalMeshMgr::feedScrollList(LLScrollListCtrl* ctrl) 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 index 955432e869..e0e46a9e26 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -37,6 +37,7 @@ #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" @@ -50,6 +51,7 @@ class LLScrollListCtrl; class LLViewerObject; +class LLVOVolume; class LLVolume; // A single local mesh file and its decoded, in-memory representation. @@ -87,16 +89,26 @@ class LLLocalMesh void setSpawnWhenReady(bool b) { mSpawnWhenReady = b; } bool wantsSpawn() const { return mSpawnWhenReady; } - // Main-thread completion hooks driven by the load callback. - void onLoadComplete(LLModelLoader::scene& scene); - void markFailed() { mState = ST_FAILED; } - 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(); - void assembleFromScene(LLModelLoader::scene& scene); + + // Assemble decoded geometry and commit it to this unit; returns false (and + // leaves any previously loaded geometry untouched) if the scene yielded + // nothing, so a failed live-reload keeps showing the last good mesh. + bool ingestScene(LLModelLoader::scene& scene); + 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 + void finishReload(bool ok); // clear in-flight state after the parse returns + void regenerateWorldID(); // mint a fresh world id (keeps skin id in sync) + bool isReloading() const { return mReloading; } std::string mFilename; std::string mShortName; @@ -117,6 +129,22 @@ class LLLocalMesh S32 mNumTriangles; S32 mNumJoints; bool mTruncated; // geometry exceeded MAX_MODEL_FACES and was clipped + + // 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 @@ -151,16 +179,31 @@ class LLLocalMeshMgr : public LLSingleton void feedScrollList(LLScrollListCtrl* ctrl); + // 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(); + private: LLUUID addUnitInternal(const std::string& filename); void despawnForWorldID(const LLUUID& world_id); + // After a successful reload, re-point our spawned objects at the unit's new + // world id so the repository serves the freshly decoded geometry. + void repointSpawnedObjects(const LLUUID& old_world_id, LLLocalMesh* unit); + // Apply a unit's mesh geometry/skin to an existing spawned object (shared by + // the spawn and reload paths). Does not touch the object's transform. + void applyUnitGeometry(LLVOVolume* vol, const LLLocalMesh* unit); + typedef std::list::iterator local_list_iter; typedef std::list::const_iterator local_list_citer; std::list mMeshList; // Client-only objects we've rezzed, paired with the world ID they show. std::vector > > mSpawnedObjects; + + LLLocalMeshTimer mTimer; // drives live-reload polling }; #endif // LL_LLLOCALMESH_H From 1bccb2a3c3754f6cf0ef6654969f2baa20e0be4a Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 00:54:21 -0400 Subject: [PATCH 07/58] Local mesh preview: stop rigged meshes from deforming the agent avatar Loading a rigged mesh deformed the user's own avatar. The model loaders resolve rig joints via a lookup callback, and the DAE loader writes the model's joint-position overrides straight onto the returned LLJoint (lldaeloader.cpp: pJoint->addAttachmentPosOverride(...)). Our callback returned gAgentAvatarp's live joints, so those overrides landed on the agent. Fix: resolve joints against a dedicated, never-rendered UI avatar (CO_FLAG_UI_AVATAR), exactly as LLModelPreview does for the upload floater. The manager lazily creates one preview avatar and uses it for the joint alias map, the joint lookup callback, and the glTF rest skeleton; loader override writes now land there instead of on the agent. The avatar is markDead'd when the manager is torn down. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 70 +++++++++++++++++++++++++++++------ indra/newview/lllocalmesh.h | 9 ++++- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index f70a2c0954..6df8361aa1 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -41,6 +41,7 @@ #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 "llprimitive.h" // LL_PCODE_VOLUME @@ -70,22 +71,24 @@ namespace 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() but against the agent avatar. - void buildJointAliases(JointMap& joint_map) + // mirroring LLModelPreview::getJointAliases(). Resolved against the preview + // avatar (never the agent). + void buildJointAliases(JointMap& joint_map, LLVOAvatar* av) { - if (!isAgentAvatarValid()) + if (!av) { return; } - joint_map = gAgentAvatarp->getJointAliases(); + joint_map = av->getJointAliases(); std::vector cv_names, attach_names; - gAgentAvatarp->getSortedJointNames(1, cv_names); - gAgentAvatarp->getSortedJointNames(2, attach_names); + av->getSortedJointNames(1, cv_names); + av->getSortedJointNames(2, attach_names); for (const std::string& name : cv_names) { joint_map[name] = name; @@ -219,11 +222,14 @@ namespace return LLVector3(size[0], size[1], size[2]); } - // LLModelLoader::joint_lookup_func_t -- resolve a joint name against the - // agent's skeleton. (opaque is the LoadContext, unused here.) - LLJoint* lookupJoint(const std::string& name, void* /*opaque*/) + // 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) { - return isAgentAvatarValid() ? gAgentAvatarp->getJoint(name) : nullptr; + LoadContext* ctx = static_cast(opaque); + return (ctx && ctx->mAvatar) ? ctx->mAvatar->getJoint(name) : nullptr; } // LLModelLoader::state_callback_t -- record the last state for diagnostics. @@ -349,8 +355,14 @@ void LLLocalMesh::startLoad() 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); + buildJointAliases(joint_alias_map, preview_av); LLModelLoader::load_callback_t load_cb = onModelLoaded; LLModelLoader::joint_lookup_func_t joint_cb = lookupJoint; @@ -378,7 +390,10 @@ void LLLocalMesh::startLoad() else // FMT_GLTF { std::vector viewer_skeleton; - gAgentAvatarp->getJointMatricesAndHierarhy(viewer_skeleton); + if (preview_av) + { + preview_av->getJointMatricesAndHierarhy(viewer_skeleton); + } ctx->mLoader = new LLGLTFLoader( mFilename, LLModel::LOD_HIGH, @@ -584,6 +599,37 @@ LLLocalMeshMgr::~LLLocalMeshMgr() delete unit; } mMeshList.clear(); + + if (mPreviewAvatar.notNull()) + { + mPreviewAvatar->markDead(); + mPreviewAvatar = nullptr; + } +} + +LLVOAvatar* LLLocalMeshMgr::getPreviewAvatar() +{ + 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 + 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) diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index e0e46a9e26..7b655616f2 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -51,6 +51,7 @@ class LLScrollListCtrl; class LLViewerObject; +class LLVOAvatar; class LLVOVolume; class LLVolume; @@ -185,6 +186,11 @@ class LLLocalMeshMgr : public LLSingleton // 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. + LLVOAvatar* getPreviewAvatar(); + private: LLUUID addUnitInternal(const std::string& filename); void despawnForWorldID(const LLUUID& world_id); @@ -203,7 +209,8 @@ class LLLocalMeshMgr : public LLSingleton // Client-only objects we've rezzed, paired with the world ID they show. std::vector > > mSpawnedObjects; - LLLocalMeshTimer mTimer; // drives live-reload polling + LLLocalMeshTimer mTimer; // drives live-reload polling + LLPointer mPreviewAvatar; // skeleton for joint resolution (never the agent) }; #endif // LL_LLLOCALMESH_H From a358d1ec176d1df896c6cf14e682a4e8e05523d7 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 01:15:58 -0400 Subject: [PATCH 08/58] Local mesh preview: clean up preview objects/avatar on shutdown The manager holds LLPointers to the client-only objects it rezzes and to the joint-resolution preview avatar -- both live in gObjectList. Left to singleton teardown, those references would outlive the viewer object list and could markDead objects against already-gone regions. - Add LLLocalMeshMgr::cleanup() (idempotent): markDead + release all spawned objects and the preview avatar, and stop the reload timer. The unit destructor now routes through it. - Call it from LLViewerObjectList::killAllObjects() so these objects are released while the object list and regions are still valid (markDead is idempotent, so the kill loop re-marking them is fine). - Tear the manager down in LLMeshRepository::shutdown() (deleteSingleton), symmetric with its creation in LLMeshRepository::init(); this frees the decoded units after killAllObjects has already released the objects. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 25 ++++++++++++++++++++++++- indra/newview/lllocalmesh.h | 5 +++++ indra/newview/llmeshrepository.cpp | 8 ++++++++ indra/newview/llviewerobjectlist.cpp | 9 +++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 6df8361aa1..674860d78a 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -594,15 +594,38 @@ LLLocalMeshMgr::LLLocalMeshMgr() 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& spawned : mSpawnedObjects) + { + if (spawned.second.notNull() && !spawned.second->isDead()) + { + spawned.second->markDead(); + } + } + mSpawnedObjects.clear(); if (mPreviewAvatar.notNull()) { - mPreviewAvatar->markDead(); + if (!mPreviewAvatar->isDead()) + { + mPreviewAvatar->markDead(); + } mPreviewAvatar = nullptr; } } diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 7b655616f2..d3dec5d3d1 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -191,6 +191,11 @@ class LLLocalMeshMgr : public LLSingleton // does not deform the real agent avatar. Created lazily on first load. LLVOAvatar* getPreviewAvatar(); + // 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(); + private: LLUUID addUnitInternal(const std::string& filename); void despawnForWorldID(const LLUUID& world_id); diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index 5d2c8825a1..4abc7ebd35 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -4249,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) diff --git a/indra/newview/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index 34ead9c963..05f1e99021 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" @@ -1433,6 +1434,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(); From d7381981495e3012282c45a09d42c372f2891900 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 01:50:34 -0400 Subject: [PATCH 09/58] Local mesh preview: spawn the whole model as a linkset, matching the upload result A file with more than 8 faces (or multiple mesh nodes) was merged into one volume and clipped at 8 faces, dropping the rest. An actual upload of the same file splits it into one mesh per LLModel (<= 8 faces each, the loader already does this via trimVolumeFacesToSize) and rezzes them as a linkset. Reproduce that exactly. LLLocalMesh now holds a vector of LLLocalMeshPart -- one per LLModel -- each with its own world id, normalized geometry (unit box + authored scale, as the upload path does), and an offset giving its position within the model. ingestScene builds the parts (no merge, no cap) and computes each part's offset from the combined model centre. spawnInWorld creates one client-only prim per part and links them into a single linkset rooted at the first prim: addChild() sets the parent, and setDrawableParent parents the drawable (a client-only linkset must do this itself -- server linksets get it in processUpdateMessage). Each prim is placed at the model centre + its offset; the whole linkset drops in front of the agent and selects/moves as one, like a rezzed upload. Live reload now replaces the linkset in place, preserving the root's position and rotation. The repository injection resolves per-part world ids: getVolumeForWorldID / getSkinInfoForWorldID / isLocal walk all units' parts, replacing the old single-world-id unit lookups. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 419 +++++++++++++++-------------- indra/newview/lllocalmesh.h | 77 +++--- indra/newview/llmeshrepository.cpp | 18 +- 3 files changed, 265 insertions(+), 249 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 674860d78a..7fd96f7b60 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -34,6 +34,7 @@ /* geometry */ #include "llmatrix4a.h" +#include "llquaternion.h" // linkset root rotation across reload #include "llvolume.h" #include "llvolumemgr.h" // LLVolumeLODGroup @@ -141,17 +142,18 @@ namespace } } - // Normalize a merged face set into a unit box centred at the origin and - // return its authored size, mirroring LLModel::normalizeVolumeFaces() (the - // upload path's convention). The returned size becomes the spawned object's - // prim scale, so the preview renders at native dimensions, is centred on its - // pivot, and reports correct extents to the build tools. Tangents are - // intentionally not touched here -- cacheOptimize() generates them - // afterwards on the already-normalized geometry. - LLVector3 normalizeFaces(std::vector& faces) + // Normalize a face set into a unit box centred at the origin and return its + // authored size, mirroring LLModel::normalizeVolumeFaces() (the upload path's + // convention). The returned size becomes the prim scale, so the preview + // renders at native dimensions, is centred on its pivot, and reports correct + // extents to the build tools. center_out (if given) receives the pre-normalize + // centre, used to place the part within the model. Tangents are intentionally + // not touched here -- cacheOptimize() generates them on the normalized geometry. + LLVector3 normalizeFaces(std::vector& faces, LLVector3* center_out = nullptr) { if (faces.empty()) { + if (center_out) center_out->setZero(); return LLVector3(1.f, 1.f, 1.f); } @@ -168,6 +170,12 @@ namespace trans.setAdd(min, max); trans.mul(-0.5f); + if (center_out) + { + // Pre-normalize centre (in the faces' current/scene space). + center_out->set(-trans[0], -trans[1], -trans[2]); + } + // Size along each axis (guard zero-thickness axes against div-by-zero). LLVector4a size; size.setSub(max, min); @@ -244,9 +252,9 @@ namespace // 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->getWorldID() << "]: " - << unit->getNumFaces() << " faces, " << unit->getNumVertices() << " verts, " - << unit->getNumTriangles() << " tris, size " << unit->getScale() << ", " + 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; } @@ -292,14 +300,11 @@ LLLocalMesh::LLLocalMesh(std::string filename) , mNumVertices(0) , mNumTriangles(0) , mNumJoints(0) - , mScale(1.f, 1.f, 1.f) - , mTruncated(false) , mReloading(false) , mPendingModified() , mFailedModified() { mTrackingID.generate(); - mWorldID.generate(); std::string ext = gDirUtilp->getExtension(mFilename); if (ext == "dae") @@ -417,15 +422,16 @@ void LLLocalMesh::startLoad() bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) { - // Assemble into locals first; members are only overwritten once we know the - // parse produced geometry, so a failed reload preserves the last good mesh. - std::vector faces; - const LLMeshSkinInfo* skin_src = nullptr; - S32 num_vertices = 0; - S32 num_triangles = 0; - bool truncated = false; - - for (LLModelLoader::scene::iterator iter = scene.begin(); iter != scene.end() && !truncated; ++iter) + // 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. + std::vector parts; + std::vector centers; // scene-space centre of each part (parallel to parts) + S32 num_vertices = 0, num_triangles = 0, num_joints = 0; + + for (auto iter = scene.begin(); iter != scene.end(); ++iter) { LLMatrix4a mat; mat.loadu(iter->first); @@ -433,104 +439,106 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) for (LLModelInstance& instance : iter->second) { LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); - if (!mdl) + if (!mdl || mdl->getNumVolumeFaces() <= 0) { continue; } - // Capture skin from the first rigged model encountered. - if (!skin_src && !mdl->mSkinInfo.mJointNames.empty()) - { - skin_src = &mdl->mSkinInfo; - } - + // Collect this model's faces, baking the instance transform so the + // part lands in the model's authored world space. + std::vector faces; + faces.reserve(mdl->getNumVolumeFaces()); for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) { - if ((S32)faces.size() >= MAX_MODEL_FACES) - { - // Faithful to the upload limit; warn and clip the remainder. - truncated = true; - break; - } - LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy transformFace(face, mat); - num_vertices += face.mNumVertices; num_triangles += face.mNumIndices / 3; faces.push_back(face); } + if (faces.empty()) + { + continue; + } - if (truncated) + // Normalize to a unit box centred at origin; keep the size (prim + // scale) and scene-space centre (placement within the model). + LLVector3 center; + LLLocalMeshPart part; + part.mScale = normalizeFaces(faces, ¢er); + part.mWorldID.generate(); + part.mNumFaces = (S32)faces.size(); + + 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. + if (!part.mVolume->cacheOptimize(true)) { - break; + 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. + LLSD sd = mdl->mSkinInfo.asLLSD(true, mdl->mSkinInfo.mLockScaleIfJointPosition); + part.mSkinInfo = new LLMeshSkinInfo(part.mWorldID, sd); + num_joints = llmax(num_joints, (S32)mdl->mSkinInfo.mJointNames.size()); + } + + parts.push_back(part); + centers.push_back(center); } } - if (faces.empty()) + if (parts.empty()) { LL_WARNS("LocalMesh") << "Local mesh produced no geometry: " << mFilename << LL_ENDL; return false; // keep any previously loaded geometry intact } - // Static meshes: re-normalize the merged geometry into a unit box centred at - // the origin and keep the authored size for the prim scale. Both loaders - // normalize per-model, but baking the instance transforms above puts the - // merged result back into authored world space (often large and off-origin), - // so the object pivot, bounding box and build-tool size would all be wrong - // without this. Rigged meshes stay in skin/bind space -- the skeleton, not - // the object transform, drives their placement (handled at rigged-attach). - LLVector3 scale(1.f, 1.f, 1.f); - if (!skin_src) - { - scale = normalizeFaces(faces); - } - - LLVolumeParams params; - params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); - LLPointer volume = new LLVolume(params, 1.f); - volume->copyFacesFrom(faces); - - // Optimize the index buffer and generate tangents, matching what - // unpackVolumeFaces() does for a real mesh asset. Loaded mesh assets are - // required to carry tangents (see LLVolume::genTangents) and raycast - // picking dereferences them, so this must run before setMeshAssetLoaded(). - if (!volume->cacheOptimize(true)) + // Combined bounding box across all parts -> the model centre. Each part's + // offset is relative to it, so the spawn can drop the whole model centred in + // front of the agent and the parts assemble in their authored positions. + LLVector3 cmin = centers[0] - parts[0].mScale * 0.5f; + LLVector3 cmax = centers[0] + parts[0].mScale * 0.5f; + for (size_t i = 0; i < parts.size(); ++i) { - LL_WARNS("LocalMesh") << "cacheOptimize failed for '" << mShortName << "'" << LL_ENDL; + update_min_max(cmin, cmax, centers[i] - parts[i].mScale * 0.5f); + update_min_max(cmin, cmax, centers[i] + parts[i].mScale * 0.5f); } - volume->setMeshAssetLoaded(true); - - LLPointer skin; - S32 num_joints = 0; - if (skin_src) + const LLVector3 model_center = (cmin + cmax) * 0.5f; + for (size_t i = 0; i < parts.size(); ++i) { - // Round-trip through LLSD to get a clean, owned copy bound to our UUID. - LLSD sd = skin_src->asLLSD(true, skin_src->mLockScaleIfJointPosition); - skin = new LLMeshSkinInfo(mWorldID, sd); - num_joints = (S32)skin_src->mJointNames.size(); + parts[i].mOffset = centers[i] - model_center; } // Commit. - mVolume = volume; - mSkinInfo = skin; // null clears any prior rig - mScale = scale; - mNumFaces = (S32)faces.size(); + mParts = std::move(parts); + mNumFaces = 0; + for (const LLLocalMeshPart& p : mParts) { mNumFaces += p.mNumFaces; } mNumVertices = num_vertices; mNumTriangles = num_triangles; mNumJoints = num_joints; - mTruncated = truncated; mState = ST_LOADED; mLastModified = mPendingModified; // the exact version we just parsed - if (truncated) + return true; +} + +bool LLLocalMesh::isRigged() const +{ + for (const LLLocalMeshPart& p : mParts) { - LL_WARNS("LocalMesh") << "Local mesh '" << mShortName << "' exceeds " << MAX_MODEL_FACES - << " faces; extra faces were dropped from the preview." << LL_ENDL; + if (p.mSkinInfo.notNull()) + { + return true; + } } - - return true; + return false; } bool LLLocalMesh::pollForReload() @@ -575,15 +583,6 @@ void LLLocalMesh::finishReload(bool ok) } } -void LLLocalMesh::regenerateWorldID() -{ - mWorldID.generate(); - if (mSkinInfo.notNull()) - { - mSkinInfo->mMeshID = mWorldID; // keep the skin's id in sync with the new world id - } -} - /*=======================================*/ /* LLLocalMeshMgr: manager class */ /*=======================================*/ @@ -701,7 +700,7 @@ void LLLocalMeshMgr::delUnit(LLUUID tracking_id) LLLocalMesh* unit = *iter; if (unit->getTrackingID() == tracking_id) { - despawnForWorldID(unit->getWorldID()); + despawnUnit(tracking_id); iter = mMeshList.erase(iter); delete unit; } @@ -729,40 +728,40 @@ LLUUID LLLocalMeshMgr::getUnitID(const std::string& filename) const return LLUUID::null; } -LLUUID LLLocalMeshMgr::getTrackingID(const LLUUID& world_id) const +const LLLocalMeshPart* LLLocalMeshMgr::findPart(const LLUUID& world_id) const { - for (LLLocalMesh* unit : mMeshList) + if (world_id.isNull()) { - if (unit->getWorldID() == world_id) - { - return unit->getTrackingID(); - } + return nullptr; } - return LLUUID::null; -} - -LLUUID LLLocalMeshMgr::getWorldID(const LLUUID& tracking_id) const -{ for (LLLocalMesh* unit : mMeshList) { - if (unit->getTrackingID() == tracking_id) + for (const LLLocalMeshPart& part : unit->getParts()) { - return unit->getWorldID(); + if (part.mWorldID == world_id) + { + return ∂ + } } } - return LLUUID::null; + return nullptr; } bool LLLocalMeshMgr::isLocal(const LLUUID& world_id) const { - for (LLLocalMesh* unit : mMeshList) - { - if (unit->getWorldID() == world_id) - { - return true; - } - } - return false; + 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 @@ -789,18 +788,6 @@ LLLocalMesh* LLLocalMeshMgr::getUnit(const LLUUID& tracking_id) const return nullptr; } -LLLocalMesh* LLLocalMeshMgr::getUnitByWorldID(const LLUUID& world_id) const -{ - for (LLLocalMesh* unit : mMeshList) - { - if (unit->getWorldID() == world_id) - { - return unit; - } - } - return nullptr; -} - bool LLLocalMeshMgr::isLocalPreview(const LLViewerObject* obj) const { if (!obj) @@ -820,7 +807,7 @@ bool LLLocalMeshMgr::isLocalPreview(const LLViewerObject* obj) const LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) { LLLocalMesh* unit = getUnit(tracking_id); - if (!unit || !unit->getValid() || !unit->getVolume()) + if (!unit || !unit->getValid() || unit->getParts().empty()) { LL_WARNS("LocalMesh") << "spawnInWorld: no valid unit for " << tracking_id << LL_ENDL; return nullptr; @@ -832,72 +819,112 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) return nullptr; } - LLViewerObject* obj = gObjectList.createObjectViewer(LL_PCODE_VOLUME, gAgent.getRegion()); - LLVOVolume* vol = dynamic_cast(obj); - if (!vol) + const std::vector& parts = unit->getParts(); + + // Live reload: if a linkset already exists for this unit, preserve its root + // transform and then replace it with the freshly decoded geometry. + LLVector3 base; + LLQuaternion root_rot; + bool had_prev = false; + for (const auto& spawned : mSpawnedObjects) { - LL_WARNS("LocalMesh") << "spawnInWorld: failed to create volume object" << LL_ENDL; - if (obj) + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) { - obj->markDead(); + base = spawned.second->getPositionAgent(); + root_rot = spawned.second->getRotation(); + had_prev = true; + break; // the first entry for a tracking id is the linkset root } - return nullptr; + } + despawnUnit(tracking_id); + + // `base` is the root prim's world position. For a fresh spawn, place the + // model's centre a few metres in front of the agent (root = centre + offset0). + if (!had_prev) + { + base = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f + parts[0].mOffset; + } + + // One prim per part, linked into a single linkset rooted at the first prim -- + // the same structure an upload of this file would rez. + 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") << "spawnInWorld: failed to create volume object" << LL_ENDL; + if (obj) { obj->markDead(); } + continue; + } + + // Selectable and movable with the standard build tools; LLSelectMgr + // suppresses server traffic for these (gated on isLocalPreview). Full + // owner permission flags let the tools enable manipulation. + vol->mbCanSelect = 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); + + // Place each prim at the model centre + its offset. Children are already + // parented, so setPositionAgent converts to a parent-relative offset; the + // root's rotation is still identity here, making that a pure translation. + vol->setPositionAgent(base + (part.mOffset - parts[0].mOffset)); + + mSpawnedObjects.emplace_back(tracking_id, obj); + } + + if (root && had_prev) + { + root->setRotation(root_rot); // restore the linkset orientation across reload } - // Selectable and movable with the standard build tools. LLSelectMgr - // suppresses all server traffic for these client-only objects (gated on - // isLocalPreview), and we set full owner permission flags so the tools - // enable manipulation (permYouOwner/permModify/permMove read these flags). - vol->mbCanSelect = true; - vol->setFlagsWithoutUpdate(FLAGS_OBJECT_YOU_OWNER | FLAGS_OBJECT_MODIFY | FLAGS_OBJECT_MOVE | FLAGS_OBJECT_COPY | FLAGS_OBJECT_TRANSFER, true); - - // Build the drawable, then force a valid LOD. LLDrawable's constructor calls - // setNoLOD(); with mLOD == NO_LOD, LLPrimitive::setVolume builds a - // placeholder cube and returns false, so LLVOVolume::setVolume skips - // loadMesh entirely (giving a grey cube and a face-count churn that - // corrupts the heap). A real LOD makes the repository injection serve our - // decoded geometry directly, with the right face count from the start. - gPipeline.createObject(obj); - vol->setLOD(LLVolumeLODGroup::NUM_LODS - 1); - - // Place a few meters in front of the agent. The geometry was normalized to a - // unit box centred on the origin, so the prim scale carries the authored size - // and the pivot sits at the geometry's centre. Scale/position are set here - // (spawn only); live reload preserves whatever transform the user has set. - const LLVector3 pos = gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f; - vol->setPositionAgent(pos); - vol->setScale(unit->getScale(), false); - - applyUnitGeometry(vol, unit); - - mSpawnedObjects.emplace_back(unit->getWorldID(), obj); - - LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' (" - << (vol->getVolume() ? vol->getVolume()->getNumVolumeFaces() : 0) - << " faces) at " << pos << LL_ENDL; - return obj; + LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' as " + << unit->getNumParts() << " prim(s), " << unit->getNumFaces() << " faces" << LL_ENDL; + return root; } -void LLLocalMeshMgr::applyUnitGeometry(LLVOVolume* vol, const LLLocalMesh* unit) +void LLLocalMeshMgr::applyPartGeometry(LLVOVolume* vol, const LLLocalMeshPart& part) { - // isSculpted()/isMesh() key off the PARAMS_SCULPT extra param (not the - // volume params alone). Without it, LLVOVolume::setVolume skips the entire - // mesh-load block and the object stays a plain prim. local_origin=false so - // nothing is sent to the sim for this client-only object. + // 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(unit->getWorldID(), LL_SCULPT_TYPE_MESH); + sculpt_params.setSculptTexture(part.mWorldID, LL_SCULPT_TYPE_MESH); vol->setParameterEntry(LLNetworkData::PARAMS_SCULPT, sculpt_params, false); - // Reference the local mesh by its world UUID; the repository injection - // resolves it to the decoded geometry. On reload the world id has changed, - // so setVolume re-runs the mesh path and picks up the fresh geometry/skin. + // 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(unit->getWorldID(), LL_SCULPT_TYPE_MESH); + params.setSculptID(part.mWorldID, LL_SCULPT_TYPE_MESH); vol->setVolume(params, LLVolumeLODGroup::NUM_LODS - 1); // Give every face a visible default texture (file materials are not yet - // applied in this milestone). The face count can change across reloads. + // applied in this milestone). LLVolume* v = vol->getVolume(); const S32 num_faces = v ? v->getNumVolumeFaces() : 0; if (num_faces > 0) @@ -934,11 +961,10 @@ void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scen unit->finishReload(assembled); if (assembled) { - // Swap geometry under a fresh world id and re-point our spawns at it. - const LLUUID old_world = unit->getWorldID(); - unit->regenerateWorldID(); logUnit("Reloaded", unit); - repointSpawnedObjects(old_world, unit); + // Replace the existing linkset with the new geometry; spawnInWorld + // preserves the root transform across the swap. + spawnInWorld(tracking_id); } return; } @@ -959,27 +985,6 @@ void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scen } } -void LLLocalMeshMgr::repointSpawnedObjects(const LLUUID& old_world_id, LLLocalMesh* unit) -{ - const LLUUID new_world_id = unit->getWorldID(); - for (auto& spawned : mSpawnedObjects) - { - if (spawned.first != old_world_id) - { - continue; - } - LLViewerObject* obj = spawned.second.get(); - if (obj && !obj->isDead()) - { - if (LLVOVolume* vol = dynamic_cast(obj)) - { - applyUnitGeometry(vol, unit); // keeps the object's transform - } - } - spawned.first = new_world_id; // track the new id for future reloads/despawn - } -} - void LLLocalMeshMgr::doUpdates() { // Stop/restart around the sweep so a long poll can't re-enter via the timer. @@ -1013,15 +1018,15 @@ void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames) } } -void LLLocalMeshMgr::despawnForWorldID(const LLUUID& world_id) +void LLLocalMeshMgr::despawnUnit(const LLUUID& tracking_id) { for (auto iter = mSpawnedObjects.begin(); iter != mSpawnedObjects.end(); ) { - if (iter->first == world_id) + if (iter->first == tracking_id) { if (iter->second.notNull() && !iter->second->isDead()) { - iter->second->markDead(); + iter->second->markDead(); // root markDead cascades to its linked children } iter = mSpawnedObjects.erase(iter); } diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index d3dec5d3d1..220a0e464a 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -55,7 +55,23 @@ class LLVOAvatar; class LLVOVolume; class LLVolume; -// A single local mesh file and its decoded, in-memory representation. +// 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. +struct LLLocalMeshPart +{ + LLUUID mWorldID; // unique mesh id objects/repository reference + LLPointer mVolume; // normalized geometry (<= 8 faces) + LLPointer mSkinInfo; // null if not rigged + LLVector3 mScale; // authored size -> prim scale + LLVector3 mOffset; // part centre relative to the whole-model centre + S32 mNumFaces = 0; +}; + +// A single local mesh file and its decoded, in-memory representation (one or more +// parts spawned together as a linkset). class LLLocalMesh { public: @@ -65,22 +81,17 @@ class LLLocalMesh std::string getFilename() const { return mFilename; } std::string getShortName() const { return mShortName; } LLUUID getTrackingID() const { return mTrackingID; } - LLUUID getWorldID() const { return mWorldID; } bool getValid() const { return mState == ST_LOADED; } bool isLoading() const { return mState == ST_LOADING; } bool isFailed() const { return mState == ST_FAILED; } - // Decoded geometry/skin -- consumed by the mesh repository injection. - LLVolume* getVolume() const { return mVolume; } - const LLMeshSkinInfo* getSkinInfo() const { return mSkinInfo; } - bool isRigged() const { return mSkinInfo.notNull(); } - - // Authored bounding-box size; the spawned static preview uses this as its - // prim scale. (1,1,1) when rigged (the rig, not the object scale, governs). - LLVector3 getScale() const { return mScale; } + // 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 // 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; } @@ -98,9 +109,10 @@ class LLLocalMesh void startLoad(); - // Assemble decoded geometry and commit it to this unit; returns false (and - // leaves any previously loaded geometry untouched) if the scene yielded - // nothing, so a failed live-reload keeps showing the last good mesh. + // 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); void markFailed() { mState = ST_FAILED; } @@ -108,28 +120,23 @@ class LLLocalMesh // async re-parse. The geometry swap happens back in the load callback. bool pollForReload(); // true if a reload was started this poll void finishReload(bool ok); // clear in-flight state after the parse returns - void regenerateWorldID(); // mint a fresh world id (keeps skin id in sync) bool isReloading() const { return mReloading; } std::string mFilename; std::string mShortName; LLUUID mTrackingID; // stable, identifies this unit in UI - LLUUID mWorldID; // stable mesh UUID objects reference (kept across reloads) EFormat mFormat; EState mState; bool mSpawnWhenReady; std::filesystem::file_time_type mLastModified; // for live reload (M3) - LLPointer mVolume; // assembled high-LOD geometry, served for all LODs - LLPointer mSkinInfo; // null if not rigged - LLVector3 mScale; // authored size; prim scale for the static preview ((1,1,1) when rigged) + std::vector mParts; // one per LLModel; spawned together as a linkset - S32 mNumFaces; + S32 mNumFaces; // totals across all parts S32 mNumVertices; S32 mNumTriangles; S32 mNumJoints; - bool mTruncated; // geometry exceeded MAX_MODEL_FACES and was clipped // Live-reload bookkeeping. bool mReloading; // an async re-parse is in flight @@ -161,19 +168,22 @@ class LLLocalMeshMgr : public LLSingleton void delUnit(LLUUID tracking_id); LLUUID getUnitID(const std::string& filename) const; - LLUUID getTrackingID(const LLUUID& world_id) const; - LLUUID getWorldID(const LLUUID& tracking_id) const; - bool isLocal(const LLUUID& world_id) const; std::string getFilename(const LLUUID& tracking_id) const; LLLocalMesh* getUnit(const LLUUID& tracking_id) const; - LLLocalMesh* getUnitByWorldID(const LLUUID& world_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; // True if the object is one of our client-only in-world preview spawns. // Used by LLSelectMgr to suppress all server traffic for these objects. bool isLocalPreview(const LLViewerObject* obj) const; - // Create a client-only LLVOVolume in-world referencing the unit's mesh. + // Create the client-only linkset in-world referencing the unit's parts. If a + // linkset for this unit already exists (live reload), it is replaced in place + // and the root's transform preserved. 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); @@ -198,20 +208,21 @@ class LLLocalMeshMgr : public LLSingleton private: LLUUID addUnitInternal(const std::string& filename); - void despawnForWorldID(const LLUUID& world_id); + void despawnUnit(const LLUUID& tracking_id); - // After a successful reload, re-point our spawned objects at the unit's new - // world id so the repository serves the freshly decoded geometry. - void repointSpawnedObjects(const LLUUID& old_world_id, LLLocalMesh* unit); - // Apply a unit's mesh geometry/skin to an existing spawned object (shared by - // the spawn and reload paths). Does not touch the object's transform. - void applyUnitGeometry(LLVOVolume* vol, const LLLocalMesh* unit); + // Find a decoded part by its world id (across all loaded units). + const LLLocalMeshPart* findPart(const LLUUID& world_id) const; + // 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; - // Client-only objects we've rezzed, paired with the world ID they show. + // Client-only objects we've rezzed, keyed by the tracking id of the unit they + // belong to. A unit's linkset shares one tracking id; the first entry pushed + // for a tracking id is the linkset root. std::vector > > mSpawnedObjects; LLLocalMeshTimer mTimer; // drives live-reload polling diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index 4abc7ebd35..39fdd9f07b 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -4399,15 +4399,15 @@ S32 LLMeshRepository::loadMesh(LLVOVolume* vobj, const LLVolumeParams& mesh_para // of issuing an asset fetch. The same geometry is served for every LOD. if (LLLocalMeshMgr::instanceExists()) { - LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_params.getSculptID()); - if (unit && unit->getVolume()) + 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) { if (!sys_volume->isMeshAssetLoaded()) { - sys_volume->copyVolumeFaces(unit->getVolume()); + sys_volume->copyVolumeFaces(local_volume); sys_volume->setMeshAssetLoaded(true); } LLPrimitive::getVolumeManager()->unrefVolume(sys_volume); @@ -4918,10 +4918,10 @@ const LLMeshSkinInfo* LLMeshRepository::getSkinInfo(const LLUUID& mesh_id, LLVOV // Local mesh: serve the decoded skin (or null if static) and never fetch. if (LLLocalMeshMgr::instanceExists()) { - LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_id); - if (unit) + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + if (mgr->isLocal(mesh_id)) { - return unit->getSkinInfo(); + return mgr->getSkinInfoForWorldID(mesh_id); } } @@ -5065,10 +5065,10 @@ bool LLMeshRepository::hasSkinInfo(const LLUUID& mesh_id) if (LLLocalMeshMgr::instanceExists()) { - LLLocalMesh* unit = LLLocalMeshMgr::getInstance()->getUnitByWorldID(mesh_id); - if (unit) + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + if (mgr->isLocal(mesh_id)) { - return unit->isRigged(); + return mgr->getSkinInfoForWorldID(mesh_id) != nullptr; } } From f74e46dbe15596f143bec482a0c3e7a7f722f443 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:00:30 -0400 Subject: [PATCH 10/58] Fix coroutine mutex assert in object weight floater --- indra/newview/llfloaterobjectweights.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/indra/newview/llfloaterobjectweights.cpp b/indra/newview/llfloaterobjectweights.cpp index 4b87d91bfd..c85d83cd23 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,18 @@ 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)); + LLAppViewer::instance()->postToMainCoro( + [=]() + { + 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 From 35dc9efd7c9ed11d90db0ba6174277775dc25f27 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:06:40 -0400 Subject: [PATCH 11/58] Local mesh preview: make Delete remove the preview linkset The Delete key/menu routes through LLSelectMgr::selectDelete (and selectForceDelete) which send DeRezObject/ObjectDelete to the sim. Local previews are client-only, so the sim has nothing to delete and the object just stayed. Intercept both paths: when the selection is entirely local previews (selectionAllLocalPreview), despawn them locally instead. LLLocalMeshMgr gains deletePreviewObject(), which maps a selected prim (linkset root or child) back to its unit and delUnit()s it -- tearing down the whole linkset and dropping the loaded unit so a later file save does not respawn it (live reload stops too). Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 26 +++++++++++++++++++++ indra/newview/lllocalmesh.h | 5 ++++ indra/newview/llselectmgr.cpp | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 7fd96f7b60..f1df39b1f1 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -804,6 +804,32 @@ bool LLLocalMeshMgr::isLocalPreview(const LLViewerObject* obj) const return false; } +void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) +{ + if (!obj) + { + return; + } + + // Map the object (linkset root or child) back to the unit that owns it, then + // drop the whole unit -- this despawns the entire linkset and stops live + // reload, so the deleted preview stays deleted. + LLUUID tracking_id; + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == obj) + { + tracking_id = spawned.first; + break; + } + } + + if (tracking_id.notNull()) + { + delUnit(tracking_id); + } +} + LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) { LLLocalMesh* unit = getUnit(tracking_id); diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 220a0e464a..cab0808a9e 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -181,6 +181,11 @@ class LLLocalMeshMgr : public LLSingleton // Used by LLSelectMgr to suppress all server traffic for these objects. bool isLocalPreview(const LLViewerObject* obj) const; + // Delete the preview linkset that owns this object (and the loaded unit, so a + // later file save does not respawn it). Lets the standard Delete key/menu work + // on client-only previews, which the sim delete path can't touch. + void deletePreviewObject(LLViewerObject* obj); + // Create the client-only linkset in-world referencing the unit's parts. If a // linkset for this unit already exists (live reload), it is replaced in place // and the root's transform preserved. diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 875bd278c1..a4b01b8bb9 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -148,6 +148,36 @@ static void synthesizeLocalPreviewNode(LLSelectNode* nodep, LLViewerObject* obje 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); } + +// 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) + { + // The first part of a linkset drops the whole unit; later parts no-op. + mgr->deletePreviewObject(obj.get()); + } +} // // Consts // @@ -4231,6 +4261,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; @@ -4380,6 +4418,12 @@ bool LLSelectMgr::confirmDelete(const LLSD& notification, const LLSD& response, void LLSelectMgr::selectForceDelete() { + if (selectionAllLocalPreview(mSelectedObjects)) + { + deleteLocalPreviewSelection(); + return; + } + sendListToRegions( "ObjectDelete", packDeleteHeader, From f15ca38c70fed7ccff97c74c49d75ea5b3dc0ab3 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:22:16 -0400 Subject: [PATCH 12/58] Local mesh preview: flag client-only objects on LLViewerObject The selection/delete gating asked LLLocalMeshMgr whether an object was a local preview, which scanned mSpawnedObjects on every check. Replace that with an O(1) LLViewerObject::isLocalOnly() flag -- a general "client-only object, no simulator counterpart" property -- set on each preview prim at spawn. llselectmgr's isLocalPreviewObject() now just reads the flag (no manager call, no instanceExists), and LLLocalMeshMgr::isLocalPreview() is removed. The manager still owns mSpawnedObjects for despawn/delete bookkeeping. This only addresses the object-keyed lookups. The repository injection (isLocal/getVolumeForWorldID/getSkinInfoForWorldID) is keyed by mesh world id, not by object, so it stays manager-side; those scans are over the tiny set of loaded units and can be made O(1) with a world-id map later if it ever matters. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 23 ++++------------------- indra/newview/lllocalmesh.h | 4 ---- indra/newview/llselectmgr.cpp | 3 ++- indra/newview/llviewerobject.cpp | 1 + indra/newview/llviewerobject.h | 7 +++++++ 5 files changed, 14 insertions(+), 24 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index f1df39b1f1..544a64c4c3 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -788,22 +788,6 @@ LLLocalMesh* LLLocalMeshMgr::getUnit(const LLUUID& tracking_id) const return nullptr; } -bool LLLocalMeshMgr::isLocalPreview(const LLViewerObject* obj) const -{ - if (!obj) - { - return false; - } - for (const auto& spawned : mSpawnedObjects) - { - if (spawned.second.get() == obj) - { - return true; - } - } - return false; -} - void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) { if (!obj) @@ -887,10 +871,11 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) continue; } - // Selectable and movable with the standard build tools; LLSelectMgr - // suppresses server traffic for these (gated on isLocalPreview). Full - // owner permission flags let the tools enable manipulation. + // 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 diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index cab0808a9e..85e3b34fa7 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -177,10 +177,6 @@ class LLLocalMeshMgr : public LLSingleton LLVolume* getVolumeForWorldID(const LLUUID& world_id) const; const LLMeshSkinInfo* getSkinInfoForWorldID(const LLUUID& world_id) const; - // True if the object is one of our client-only in-world preview spawns. - // Used by LLSelectMgr to suppress all server traffic for these objects. - bool isLocalPreview(const LLViewerObject* obj) const; - // Delete the preview linkset that owns this object (and the loaded unit, so a // later file save does not respawn it). Lets the standard Delete key/menu work // on client-only previews, which the sim delete path can't touch. diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index a4b01b8bb9..e7a181ee12 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -109,7 +109,8 @@ LLViewerObject* getSelectedParentObject(LLViewerObject *object) ; // and edit network traffic must be suppressed for them. static bool isLocalPreviewObject(LLViewerObject* obj) { - return obj && LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocalPreview(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 diff --git a/indra/newview/llviewerobject.cpp b/indra/newview/llviewerobject.cpp index 32e6ecf08d..b65dcf1d35 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), diff --git a/indra/newview/llviewerobject.h b/indra/newview/llviewerobject.h index 4abfb714e1..aa77425170 100644 --- a/indra/newview/llviewerobject.h +++ b/indra/newview/llviewerobject.h @@ -778,6 +778,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; From 8843c1e3bc21cdf300f42f0556e01c5ae9cef84f Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:30:38 -0400 Subject: [PATCH 13/58] Local mesh preview: gate LLViewerObject sim traffic for client-only objects A client-only object (isLocalOnly) has no simulator counterpart, and its mLocalID is a client-side id that can collide with a real region object -- so any Object* message it sends is at best ignored and at worst mutates someone else'\''s prim. Audited LLViewerObject'\''s outbound sim paths and gated them on isLocalOnly(): - fetchInventoryFromServer(): present an empty, loaded inventory (+ callback) instead of requesting task inventory. This was the observed offender -- the build floater'\''s Contents tab kicked a RequestTaskInventory for the fake id. - saveScript / moveInventory / removeInventory / updateInventory: skip the task- inventory messages (a preview holds no editable task inventory). - sendMaterialUpdate / sendShapeUpdate / sendTEUpdate: skip. Local edits still apply visually; only the sim notification is suppressed. - parameterChanged(): skip the local-origin ObjectExtraParams send (flexi/light/ sculpt/etc.). - setRenderMaterialID(): skip the ModifyMaterialParams cap queue; the predictive local render material still updates. - updateFlags(): skip ObjectFlagUpdate. - requestObjectUpdate(): skip RequestMultipleObjects (cache-miss recovery). (Transform updates already route through LLSelectMgr, which is gated on selectionAllLocalPreview. removeNVPair'\''s send is dead/commented out.) Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/llviewerobject.cpp | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/indra/newview/llviewerobject.cpp b/indra/newview/llviewerobject.cpp index b65dcf1d35..e1c0da0623 100644 --- a/indra/newview/llviewerobject.cpp +++ b/indra/newview/llviewerobject.cpp @@ -2814,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 = @@ -2855,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); @@ -2962,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; @@ -3564,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; @@ -3636,6 +3656,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. @@ -4972,6 +4994,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); @@ -4988,6 +5011,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() ); @@ -5004,6 +5028,7 @@ void LLViewerObject::sendShapeUpdate() void LLViewerObject::sendTEUpdate() const { + if (isLocalOnly()) return; // client-only object: no sim counterpart LLMessageSystem* msg = gMessageSystem; msg->newMessageFast(_PREHASH_ObjectImage); @@ -6502,7 +6527,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. @@ -6901,6 +6927,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); @@ -7494,9 +7521,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 @@ -7654,6 +7683,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; From 3942d333f10e92426f058dc5a341a9af899ae823 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:37:16 -0400 Subject: [PATCH 14/58] Local mesh preview: gate LLVOVolume media sim traffic for client-only objects LLVOVolume sends no Object* messages of its own (it relies on the base LLViewerObject sends, now gated, and mesh data comes through the repository injection). Its own server traffic is the media-on-a-prim cap paths via LLObjectMediaDataClient, which key on the object and would target the wrong / no sim object for a client-only preview. Gate them on isLocalOnly(): - requestMediaDataUpdate() (fetchMedia) - sendMediaDataUpdate() (updateMedia) - mediaNavigate broadcast (navigate) Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/llvovolume.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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)); } From 9fa8c098c5961027c0d993bf949c10b97e545ac4 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:43:49 -0400 Subject: [PATCH 15/58] Local mesh preview: release previews when their host region is torn down Preview prims (and the joint-resolution avatar) are created in the agent'\''s region, so they carry that region'\''s mRegionp. When the region is destroyed, LLViewerRegion::~LLViewerRegion -> gObjectList.killObjects(region) mark-deads them, but the manager kept LLPointers to them (and to the preview avatar), leaving dead objects dangling and the manager pointing at a defunct region. Add LLLocalMeshMgr::despawnObjectsInRegion(region) -- releases the spawned objects whose getRegion() matches (markDead + drop from mSpawnedObjects) and clears the preview avatar if it lived there -- and call it from LLViewerObjectList::killObjects(region) before the kill sweep. The loaded units remain, so a later spawn/reload re-creates the preview in whatever region is current then. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 33 ++++++++++++++++++++++++++++ indra/newview/lllocalmesh.h | 7 ++++++ indra/newview/llviewerobjectlist.cpp | 8 +++++++ 3 files changed, 48 insertions(+) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 544a64c4c3..a5adde7d13 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -629,6 +629,39 @@ void LLLocalMeshMgr::cleanup() } } +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 = mSpawnedObjects.begin(); iter != mSpawnedObjects.end(); ) + { + LLViewerObject* obj = iter->second.get(); + if (!obj || obj->isDead() || obj->getRegion() == regionp) + { + if (obj && !obj->isDead()) + { + obj->markDead(); + } + iter = mSpawnedObjects.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() { if ((mPreviewAvatar.isNull() || mPreviewAvatar->isDead()) && gAgent.getRegion()) diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 85e3b34fa7..5d04663469 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -51,6 +51,7 @@ class LLScrollListCtrl; class LLViewerObject; +class LLViewerRegion; class LLVOAvatar; class LLVOVolume; class LLVolume; @@ -207,6 +208,12 @@ class LLLocalMeshMgr : public LLSingleton // 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); void despawnUnit(const LLUUID& tracking_id); diff --git a/indra/newview/llviewerobjectlist.cpp b/indra/newview/llviewerobjectlist.cpp index 05f1e99021..4b8ed6bf37 100644 --- a/indra/newview/llviewerobjectlist.cpp +++ b/indra/newview/llviewerobjectlist.cpp @@ -1413,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; From d6986676770c32e9bac1bb60d0ea2a300f2ec01d Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:52:56 -0400 Subject: [PATCH 16/58] Local mesh preview: keep selection valid through the texture/material pickers Committing a texture (or PBR material) via the picker runs LLPanelFace's onSelectTexture/onSelectPbr -> LLSelectMgr::saveSelectedObjectTextures(), which sets every select node mValid=false and calls sendSelect() to refresh via an ObjectProperties reply. That reply never arrives for a client-only preview (and sendSelect is gated), so the node stayed invalid and the selection "broke" the moment you pressed OK. - saveSelectedObjectTextures(): for an all-local-preview selection, re-affirm the synthesized node instead of invalidate+sendSelect. - synthesizeLocalPreviewNode(): also snapshot the current face textures (as processObjectProperties would) so picker cancel/revert has a baseline -- and so the re-affirm above keeps mSavedTextures current. Covers both the texture and PBR-material pickers, OK and Cancel. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/llselectmgr.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index e7a181ee12..e931e87251 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -148,6 +148,18 @@ static void synthesizeLocalPreviewNode(LLSelectNode* nodep, LLViewerObject* obje 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; + const U8 num_tes = objectp->getNumTEs(); + texture_ids.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); + } + nodep->saveTextures(texture_ids); } // Delete a selection made up entirely of client-only previews. The sim delete @@ -5524,6 +5536,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 { From 4f8a4c7a819be109d50e52507208493a73dc1d1a Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 02:57:21 -0400 Subject: [PATCH 17/58] Local mesh preview: forbid mixing local previews and real objects in a selection A selection containing both a client-only preview and a real object slips past the all-local gating (selectionAllLocalPreview), so the normal server path would run and send the previews'\'' fake local IDs to the sim. Prevent the mix at the source: canSelectObject() -- the gate every add path goes through -- now rejects an object whose isLocalOnly() differs from the current selection'\''s (mirroring the adjacent SELECT_TYPE world/attachment/HUD mismatch rule). Single-click still replaces across the boundary (the tool deselects first, so the selection is empty at check time); only additive (shift) selection is constrained, keeping every selection homogeneous. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/llselectmgr.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index e931e87251..c661620bc1 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -8028,6 +8028,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; } From f2fa721474ea46b4476d41debb5b1504a319b1b5 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 03:05:19 -0400 Subject: [PATCH 18/58] Fix excessive warning in console draw --- indra/llui/llconsole.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 44e844ec7e4d0573e74324efd0ea96ca2bc7a5cb Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 03:14:50 -0400 Subject: [PATCH 19/58] Local mesh preview (M4): keep rigged geometry/skin asset-faithful for a 1:1 preview Groundwork for attaching rigged previews to the avatar. A rigged unit now keeps the loader'\''s geometry and skin verbatim instead of re-normalizing/baking the instance transform the way static units do: - The loaders already normalize a skinned mesh to a unit box and build a matching bind-shape matrix (the exact pair an upload stores), so re-normalizing here would desync the skinning. Keeping the loader output -> cacheOptimize(true) reproduces what unpackVolumeFaces yields for a downloaded mesh (modulo imperceptible 16-bit position quantization). - The skin copy round-trips through LLMeshSkinInfo::asLLSD(true, lockScale) / fromLLSD -- the same asset LLSD format the repository parses for real meshes -- so joint names, inverse-bind, bind-shape, alt-inverse-bind (joint position overrides), pelvis offset and lock-scale all match a real upload. - Authored size is recorded for the in-world static view; placement comes from the rig once attached, so rigged parts carry no per-part offset. Static units are unchanged (bake instance transforms + normalize for the in-world linkset). Rendering still flows through the repository injection, so when the next step attaches the linkset the rigged draw path is the same one real attachments use. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 83 +++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index a5adde7d13..512825bb66 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -427,8 +427,28 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) // 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; - std::vector centers; // scene-space centre of each part (parallel to parts) + std::vector centers; // static units only: scene-space centre per part S32 num_vertices = 0, num_triangles = 0, num_joints = 0; for (auto iter = scene.begin(); iter != scene.end(); ++iter) @@ -444,14 +464,16 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) continue; } - // Collect this model's faces, baking the instance transform so the - // part lands in the model's authored world space. std::vector faces; faces.reserve(mdl->getNumVolumeFaces()); for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) { LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy - transformFace(face, mat); + if (!unit_rigged) + { + // Static: bake the instance transform into authored world space. + transformFace(face, mat); + } num_vertices += face.mNumVertices; num_triangles += face.mNumIndices / 3; faces.push_back(face); @@ -461,14 +483,29 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) continue; } - // Normalize to a unit box centred at origin; keep the size (prim - // scale) and scene-space centre (placement within the model). - LLVector3 center; LLLocalMeshPart part; - part.mScale = normalizeFaces(faces, ¢er); part.mWorldID.generate(); part.mNumFaces = (S32)faces.size(); + if (unit_rigged) + { + // Keep the loader's (already unit-box) geometry as-is; record the + // authored size for the in-world static view. Placement comes from + // the rig once attached, so no per-part offset. + LLVector3 nscale, ntrans; + mdl->getNormalizedScaleTranslation(nscale, ntrans); + part.mScale = nscale; + part.mOffset = LLVector3::zero; + } + else + { + // Normalize to a unit box; keep size (prim scale) + scene-space + // centre (offset within the model, computed below). + LLVector3 center; + part.mScale = normalizeFaces(faces, ¢er); + centers.push_back(center); + } + LLVolumeParams vparams; vparams.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); part.mVolume = new LLVolume(vparams, 1.f); @@ -490,7 +527,6 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) } parts.push_back(part); - centers.push_back(center); } } @@ -500,20 +536,23 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) return false; // keep any previously loaded geometry intact } - // Combined bounding box across all parts -> the model centre. Each part's - // offset is relative to it, so the spawn can drop the whole model centred in - // front of the agent and the parts assemble in their authored positions. - LLVector3 cmin = centers[0] - parts[0].mScale * 0.5f; - LLVector3 cmax = centers[0] + parts[0].mScale * 0.5f; - for (size_t i = 0; i < parts.size(); ++i) - { - update_min_max(cmin, cmax, centers[i] - parts[i].mScale * 0.5f); - update_min_max(cmin, cmax, centers[i] + parts[i].mScale * 0.5f); - } - const LLVector3 model_center = (cmin + cmax) * 0.5f; - for (size_t i = 0; i < parts.size(); ++i) + if (!unit_rigged) { - parts[i].mOffset = centers[i] - model_center; + // Combined bounding box across all parts -> the model centre. Each part's + // offset is relative to it, so the spawn drops the whole model centred in + // front of the agent with the parts in their authored positions. + LLVector3 cmin = centers[0] - parts[0].mScale * 0.5f; + LLVector3 cmax = centers[0] + parts[0].mScale * 0.5f; + for (size_t i = 0; i < parts.size(); ++i) + { + update_min_max(cmin, cmax, centers[i] - parts[i].mScale * 0.5f); + update_min_max(cmin, cmax, centers[i] + parts[i].mScale * 0.5f); + } + const LLVector3 model_center = (cmin + cmax) * 0.5f; + for (size_t i = 0; i < parts.size(); ++i) + { + parts[i].mOffset = centers[i] - model_center; + } } // Commit. From dd236963712eeda94c5ea0097b4896b4a4a88b00 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 03:30:49 -0400 Subject: [PATCH 20/58] Local mesh preview (M4): wear a rigged preview on the avatar via right-click Adds a right-click "Wear/Take Off Avatar (Local Mesh)" item (visible only for a selected client-only rigged preview) that attaches/detaches the preview linkset to the agent so it deforms with the skeleton -- the same draw path a real worn mesh uses. Client-side attach reproduces a server attach'\''s end state for the linkset: - a non-zero attachment-point state on every prim (chest) so isAttachment()/ getAvatar() recognise them; - addChild() the root onto the avatar so getAvatar()'\''s parent walk reaches the agent (root directly, children via the root) -- this routes faces into the rigged draw pool; - attachObject() to parent the drawable to the joint and apply the skin'\''s joint position overrides (recursively). Detach reverses it (base LLVOAvatar::detachObject + removeChild + clear state) and drops the linkset back in-world in front of the agent. Safety: - LLViewerObject gains a setAttachmentState() setter (state is normally only set from server updates). - LLViewerJointAttachment::addObject() skips its same-item dedup for client-only objects (their item id is null; the dedup would markDead an unrelated null-id local attachment and send it an ObjectDetach the sim can'\''t act on). - despawn/cleanup/region-teardown detach an attached preview before markDead so it never dangles on the avatar. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 144 ++++++++++++++++++ indra/newview/lllocalmesh.h | 14 ++ indra/newview/llviewerjointattachment.cpp | 5 +- indra/newview/llviewermenu.cpp | 35 +++++ indra/newview/llviewerobject.h | 4 + .../skins/default/xui/en/menu_object.xml | 8 + 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 512825bb66..dd09b479ac 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -48,6 +48,7 @@ #include "llprimitive.h" // LL_PCODE_VOLUME #include "object_flags.h" // FLAGS_OBJECT_* for owner permissions #include "llscrolllistctrl.h" +#include "llselectmgr.h" // deselect before re-parenting onto the avatar #include "llviewercontrol.h" #include "llviewerobjectlist.h" #include "llviewerregion.h" @@ -653,6 +654,7 @@ void LLLocalMeshMgr::cleanup() { if (spawned.second.notNull() && !spawned.second->isDead()) { + detachRootIfAttached(spawned.second.get()); // unwear before it dies spawned.second->markDead(); } } @@ -681,6 +683,7 @@ void LLLocalMeshMgr::despawnObjectsInRegion(LLViewerRegion* regionp) { if (obj && !obj->isDead()) { + detachRootIfAttached(obj); // unwear before it dies obj->markDead(); } iter = mSpawnedObjects.erase(iter); @@ -886,6 +889,146 @@ void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) } } +LLViewerObject* LLLocalMeshMgr::findRootForObject(const LLViewerObject* obj) const +{ + if (!obj) + { + return nullptr; + } + LLUUID tracking_id; + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == obj) + { + tracking_id = spawned.first; + break; + } + } + if (tracking_id.isNull()) + { + return nullptr; + } + // The first entry pushed for a tracking id is the linkset root. + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) + { + return spawned.second.get(); + } + } + return nullptr; +} + +bool LLLocalMeshMgr::isRiggedPreview(const LLViewerObject* obj) const +{ + if (!obj || !obj->isLocalOnly()) + { + return false; + } + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == obj) + { + LLLocalMesh* unit = getUnit(spawned.first); + 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) +{ + 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: + // * a non-zero attachment-point state on every prim so isAttachment()/ + // getAvatar() recognise them (the point is cosmetic for a rigged mesh -- + // the skin drives placement); state 0x10 == ATTACHMENT_ID_FROM_STATE 1 (chest), + // * 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 and apply the skin's + // joint-position overrides (recursively, including children). + LLUUID tracking_id; + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == root) { tracking_id = spawned.first; break; } + } + const U8 attach_state = 0x10; // chest + for (auto& spawned : mSpawnedObjects) + { + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) + { + spawned.second->setAttachmentState(attach_state); + } + } + + LLSelectMgr::getInstance()->deselectAll(); // dropping the in-world selection before reparenting + gAgentAvatarp->addChild(root); + gAgentAvatarp->attachObject(root); + + LL_INFOS("LocalMesh") << "Attached local mesh preview to avatar" << LL_ENDL; +} + +void LLLocalMeshMgr::detachPreviewFromAvatar(LLViewerObject* obj) +{ + LLViewerObject* root = findRootForObject(obj); + if (!root) + { + return; + } + LLUUID tracking_id; + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.second.get() == root) { tracking_id = spawned.first; break; } + } + + detachRootIfAttached(root); + + // Clear the (cosmetic) attachment state we set on the children too. + for (auto& spawned : mSpawnedObjects) + { + if (spawned.first == tracking_id && spawned.second.notNull()) + { + spawned.second->setAttachmentState(0); + } + } + + if (isAgentAvatarValid()) + { + // Put it back in-world a few metres in front of the agent. + root->setPositionAgent(gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f); + root->markForUpdate(); + } +} + +void LLLocalMeshMgr::detachRootIfAttached(LLViewerObject* root) +{ + if (!root || !root->isAttachment() || !isAgentAvatarValid()) + { + return; + } + // Base-class detach: a clean client-side removal (avoids the inventory/sim + // traffic LLVOAvatarSelf::detachObject would attempt for a real attachment). + gAgentAvatarp->LLVOAvatar::detachObject(root); + gAgentAvatarp->removeChild(root); // also clears the object-tree parent + root->setAttachmentState(0); +} + LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) { LLLocalMesh* unit = getUnit(tracking_id); @@ -1109,6 +1252,7 @@ void LLLocalMeshMgr::despawnUnit(const LLUUID& tracking_id) { if (iter->second.notNull() && !iter->second->isDead()) { + detachRootIfAttached(iter->second.get()); // unwear before it dies iter->second->markDead(); // root markDead cascades to its linked children } iter = mSpawnedObjects.erase(iter); diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 5d04663469..6cf927f5f5 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -183,6 +183,15 @@ class LLLocalMeshMgr : public LLSingleton // on client-only previews, which the sim delete path can't touch. void deletePreviewObject(LLViewerObject* obj); + // Attach/detach a rigged preview to the agent avatar (menu-driven, M4). Each + // acts on the preview linkset that owns `obj` (root or child). 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); + void detachPreviewFromAvatar(LLViewerObject* obj); + bool isRiggedPreview(const LLViewerObject* obj) const; + bool isPreviewAttached(const LLViewerObject* obj) const; + // Create the client-only linkset in-world referencing the unit's parts. If a // linkset for this unit already exists (live reload), it is replaced in place // and the root's transform preserved. @@ -220,6 +229,11 @@ class LLLocalMeshMgr : public LLSingleton // 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); 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 1f461addd3..4a4722994d 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -6377,6 +6377,39 @@ bool enable_object_delete() return new_value; } +// Local mesh preview: wear/take-off a rigged preview on the agent avatar (M4). +// Only shown for a selected client-only rigged preview. +bool enable_attach_local_mesh() +{ + if (!LLLocalMeshMgr::instanceExists()) + { + return false; + } + LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); + return obj && LLLocalMeshMgr::getInstance()->isRiggedPreview(obj); +} + +void handle_attach_local_mesh() +{ + if (!LLLocalMeshMgr::instanceExists()) + { + return; + } + LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); + LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); + if (obj && mgr->isRiggedPreview(obj)) + { + if (mgr->isPreviewAttached(obj)) + { + mgr->detachPreviewFromAvatar(obj); + } + else + { + mgr->attachPreviewToAvatar(obj); + } + } +} + class LLObjectsReturnPackage { public: @@ -10700,6 +10733,7 @@ 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.AttachLocalMesh", boost::bind(&handle_attach_local_mesh)); 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"); @@ -10728,6 +10762,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.EnableAttachLocalMesh", boost::bind(&enable_attach_local_mesh)); enable.add("Object.EnableWear", boost::bind(&object_is_wearable)); enable.add("Object.EnableStandUp", boost::bind(&enable_object_stand_up)); diff --git a/indra/newview/llviewerobject.h b/indra/newview/llviewerobject.h index aa77425170..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; } diff --git a/indra/newview/skins/default/xui/en/menu_object.xml b/indra/newview/skins/default/xui/en/menu_object.xml index abbae4d9c9..4699d11626 100644 --- a/indra/newview/skins/default/xui/en/menu_object.xml +++ b/indra/newview/skins/default/xui/en/menu_object.xml @@ -22,6 +22,14 @@ + + + + From 3d0c482b26057b51b284fe0efcab03253589aee2 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 04:00:42 -0400 Subject: [PATCH 21/58] Local mesh preview (M4): fix attach crash via isLocalOnly guards Wearing a rigged preview crashed in RlvAttachmentLockWatchdog::onAttach: LLVOAvatar::addChild() auto-calls the virtual LLVOAvatarSelf::attachObject(), whose RLV/inventory path asserts a non-null attachment-item id -- and a client-only preview has none, so RLV_ASSERT (fatal LL_ERRS in debug-info builds) aborted. The caller worked around it by base-pinning addChild + attachObject, which double-attached and was fragile. Guard the foot-guns at the source using LLViewerObject::isLocalOnly(): * RlvAttachmentLockWatchdog::onAttach/onDetach bail for client-only objects. * LLVOAvatarSelf::attachObject still does the rigged-render refresh (updateLODRiggedAttachments) but skips inventory sync + RLV/appearance bookkeeping for them. * LLVOAvatarSelf::detachObject skips the RLV pre-pass and COF unregister. The local-mesh caller now uses the normal addChild()/detachObject() path; attach/detach are symmetric and no longer reach into base classes. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 14 +++++++--- indra/newview/llvoavatarself.cpp | 47 +++++++++++++++++++++----------- indra/newview/rlvlocks.cpp | 10 +++++++ 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index dd09b479ac..db4fb5a39c 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -978,8 +978,14 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) } 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 and applying the skin's joint-position overrides. 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); - gAgentAvatarp->attachObject(root); LL_INFOS("LocalMesh") << "Attached local mesh preview to avatar" << LL_ENDL; } @@ -1022,9 +1028,9 @@ void LLLocalMeshMgr::detachRootIfAttached(LLViewerObject* root) { return; } - // Base-class detach: a clean client-side removal (avoids the inventory/sim - // traffic LLVOAvatarSelf::detachObject would attempt for a real attachment). - gAgentAvatarp->LLVOAvatar::detachObject(root); + // Normal detach path; LLVOAvatarSelf::detachObject() detects the client-only + // flag and skips the inventory/COF/RLV bookkeeping a real detach would do. + gAgentAvatarp->detachObject(root); gAgentAvatarp->removeChild(root); // also clears the object-tree parent root->setAttachmentState(0); } 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/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())) ); From 6c96fcf7025a9e31967044d05474f3369daf1750 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 04:14:35 -0400 Subject: [PATCH 22/58] Local mesh preview (M4): fill rigged face weights from the skin map Attached rigged previews rendered as garbage -- holes, exploded verts, avatar deformation, vanishing parts -- because the injected volume had no per-vertex skin weights. Loader-built LLModels keep skin weights in mSkinWeights (a position-keyed map); the per-vertex LLVolumeFace::mWeights array is only populated when a mesh is *downloaded* (LLVolume::unpackVolumeFaces). We copy the loader's volume faces directly, so mWeights was null and the rigged draw path / LLSkinningUtil skinned the mesh to nothing. Reproduce the upload->download weight pipeline: for each rigged face vertex, look the position up via LLModel::getJointInfluences() (exactly what LLModel::writeModel does) and pack it as a download would -- component = jointIndex + weight, weight U16-quantized and clamped to [0.001, 0.999], up to 4 influences, (joint 0, ~1.0) fallback. Done before cacheOptimize() so its vertex remap carries the weights along, giving a 1:1 match with an uploaded rigged asset. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index db4fb5a39c..a5b4d26895 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -231,6 +231,57 @@ namespace return LLVector3(size[0], size[1], size[2]); } + // 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). + void populateFaceWeights(LLVolumeFace& face, LLModel& mdl) + { + 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 = mdl.getJointInfluences(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 @@ -475,6 +526,13 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) // Static: bake the instance transform into authored world space. transformFace(face, mat); } + else + { + // 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); + } num_vertices += face.mNumVertices; num_triangles += face.mNumIndices / 3; faces.push_back(face); From 34e12bdfe31da563b5519ad007d4363dfac0cc10 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 14:08:52 -0400 Subject: [PATCH 23/58] Local mesh preview (M4): don't bake joint-position overrides into rigged previews Attaching a rigged preview scrunched the legs. Cause: ingestScene baked the model's joint-position overrides (alt-inverse-bind matrices) into the preview skin via asLLSD(true, ...), and attaching applied them -- reshaping the agent's skeleton. A mesh weighted for the default skeleton (the common case, e.g. clothing) then deforms away from its bind pose. The mesh-upload floater's own preview shows the no-override look by default (LLModelPreview only applies overrides when the show_joint_overrides view toggle is on). Mirror that: asLLSD(false, false) keeps joint names, inverse bind, and bind shape (everything skinning needs) but strips the alt-inverse-bind matrices and pelvis offset, so the preview renders on the default skeleton -- 1:1 with a normal upload that doesn't include joint positions. (A future toggle can opt into joint positions for fitted bodies/heads, mirroring the upload floater's "include joint positions" checkbox.) Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index a5b4d26895..f8cec6ee55 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -571,6 +571,7 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) 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; @@ -579,8 +580,14 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) if (!mdl->mSkinInfo.mJointNames.empty()) { - // Owned copy bound to this part's world id. - LLSD sd = mdl->mSkinInfo.asLLSD(true, mdl->mSkinInfo.mLockScaleIfJointPosition); + // Owned copy bound to this part's world id. include_joints=false + // strips the alt-inverse-bind matrices (joint-position overrides) + + // pelvis offset while keeping joint names, inverse bind, and bind + // shape -- everything skinning needs. This mirrors the mesh-upload + // preview's 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. + LLSD sd = mdl->mSkinInfo.asLLSD(false, false); part.mSkinInfo = new LLMeshSkinInfo(part.mWorldID, sd); num_joints = llmax(num_joints, (S32)mdl->mSkinInfo.mJointNames.size()); } From 48d03b391ff89e9705932ed92dbf46ce0a57d4c3 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 15:15:22 -0400 Subject: [PATCH 24/58] Local mesh preview (M4): make "Detach" work on rigged previews Two problems taking a worn preview back off: 1. The standard "Detach item" (Attachment.Detach) detaches by inventory item id, which a client-only preview doesn't have, so it was a no-op. Intercept LLAttachmentDetach for isLocalOnly() objects and route to the local mesh mgr. 2. detachPreviewFromAvatar() left the linkset broken -- mesh rendered in one spot with its bounding box across the region. Cause: LLViewerJointAttachment:: removeObject() calls drawable->makeStatic(), which is guarded on !mVObjp->isAttachment(); we cleared the attachment state *after* detaching, so makeStatic was a no-op and the drawables stayed ACTIVE on the avatar's spatial bridge. Fix: clear the attachment state on the whole linkset BEFORE removeChild() (which detaches), so removeObject() re-homes the drawables into the region partition, then restore the root drawable's xform parent (setupDrawable() had parented it to the joint) so its position comes from getPositionAgent(). Detach is in place -- the same prims, so textures/colours/transforms the user applied in-world survive being taken off. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 32 ++++++++++++++++++++++++-------- indra/newview/llviewermenu.cpp | 9 +++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index f8cec6ee55..9222be3ad5 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -1058,7 +1058,7 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) void LLLocalMeshMgr::detachPreviewFromAvatar(LLViewerObject* obj) { LLViewerObject* root = findRootForObject(obj); - if (!root) + if (!root || !root->isAttachment() || !isAgentAvatarValid()) { return; } @@ -1068,23 +1068,39 @@ void LLLocalMeshMgr::detachPreviewFromAvatar(LLViewerObject* obj) if (spawned.second.get() == root) { tracking_id = spawned.first; break; } } - detachRootIfAttached(root); + LLSelectMgr::getInstance()->deselectAll(); - // Clear the (cosmetic) attachment state we set on the children too. + // 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 the 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. for (auto& spawned : mSpawnedObjects) { - if (spawned.first == tracking_id && spawned.second.notNull()) + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) { spawned.second->setAttachmentState(0); } } - if (isAgentAvatarValid()) + // 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()) { - // Put it back in-world a few metres in front of the agent. - root->setPositionAgent(gAgent.getPositionAgent() + gAgent.getAtAxis() * 3.f); - root->markForUpdate(); + 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(); } void LLLocalMeshMgr::detachRootIfAttached(LLViewerObject* root) diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 4a4722994d..5f19238102 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -7938,6 +7938,15 @@ class LLAttachmentDetach : public view_listener_t 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) {} From 8a3ff6137a59d112caeae18043003d1dbe104599 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 15:34:39 -0400 Subject: [PATCH 25/58] Local mesh preview (M4): harden detach-on-despawn; doc cleanup Cleanup pass after verifying the rigged attach/detach round trip (incl. multi-prim linksets, and teleport / logout / Delete while worn): * detachRootIfAttached() (the despawn/cleanup-path detach): clear the attachment state BEFORE detaching so removeObject()'s makeStatic() runs (it's guarded on !isAttachment()); use a single LLVOAvatar::removeChild() (which both clears the object-tree parent and detaches) instead of detachObject()+removeChild(), dropping the spurious "Calling detach on non-attached object" warning; and guard on getParent()==avatar so it only ever acts on the worn linkset root, never a child. * Drop the stale "applies joint-position overrides" comments in attachPreviewToAvatar() -- the preview skin no longer carries overrides (stripped in ingestScene to match the upload default). No behavioural change to the verified happy paths; this just removes a double-detach, a log warning, and an unreachable-on-child edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 9222be3ad5..d31f297915 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -1026,8 +1026,10 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) // * 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 and apply the skin's - // joint-position overrides (recursively, including children). + // * 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. LLUUID tracking_id; for (const auto& spawned : mSpawnedObjects) { @@ -1047,9 +1049,9 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) // 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 and applying the skin's joint-position overrides. The - // LLVOAvatarSelf::attachObject() override and the RLV watchdog detect the - // client-only flag and skip the inventory/COF/RLV bookkeeping a preview lacks. + // 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); LL_INFOS("LocalMesh") << "Attached local mesh preview to avatar" << LL_ENDL; @@ -1105,15 +1107,21 @@ void LLLocalMeshMgr::detachPreviewFromAvatar(LLViewerObject* obj) void LLLocalMeshMgr::detachRootIfAttached(LLViewerObject* root) { - if (!root || !root->isAttachment() || !isAgentAvatarValid()) + // 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; } - // Normal detach path; LLVOAvatarSelf::detachObject() detects the client-only - // flag and skips the inventory/COF/RLV bookkeeping a real detach would do. - gAgentAvatarp->detachObject(root); - gAgentAvatarp->removeChild(root); // also clears the object-tree parent + // 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) From d17c3d998c3e982dd87a6495705ad66d84d4cf47 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 16:17:11 -0400 Subject: [PATCH 26/58] Local mesh preview (M4): attach via the normal Attach menu w/ point choice The custom "Wear/Take Off Avatar (Local Mesh)" item hardcoded the chest point. Avatar rigged render order is sorted by attachment-point id, so the point must be user-selectable -- route the preview through the standard "Put on > Attach > " menu instead. * attachPreviewToAvatar() takes the chosen attachment-point id and encodes it into the state every prim carries (ATTACHMENT_ID_FROM_STATE, a symmetric nibble-swap), so getTargetAttachmentPoint() binds the linkset to that point. 0 -> default (chest). * LLObjectAttachToAvatar (the standard "Attach"/"Wear"/"Add" handler) detects isLocalOnly() and attaches the preview client-side to the picked point, skipping the sim attach + walk-to that would no-op on a client-only object. object_is_wearable() already enables the menu (the synthesized selection node owns to the agent). * Removed the bespoke menu item, handle_attach_local_mesh()/ enable_attach_local_mesh(), and their registrations. Detach continues through the standard "Detach item" (already intercepted). Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/lllocalmesh.cpp | 14 ++++-- indra/newview/lllocalmesh.h | 14 ++++-- indra/newview/llviewermenu.cpp | 48 +++++-------------- .../skins/default/xui/en/menu_object.xml | 8 ---- 4 files changed, 31 insertions(+), 53 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index d31f297915..eac83038ca 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -1007,7 +1007,7 @@ bool LLLocalMeshMgr::isPreviewAttached(const LLViewerObject* obj) const return root && root->isAttachment(); } -void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) +void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj, S32 attach_point) { if (!isAgentAvatarValid()) { @@ -1020,9 +1020,12 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) } // Reproduce a server attach's end state for this client-only linkset: - // * a non-zero attachment-point state on every prim so isAttachment()/ - // getAvatar() recognise them (the point is cosmetic for a rigged mesh -- - // the skin drives placement); state 0x10 == ATTACHMENT_ID_FROM_STATE 1 (chest), + // * 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, @@ -1035,7 +1038,8 @@ void LLLocalMeshMgr::attachPreviewToAvatar(LLViewerObject* obj) { if (spawned.second.get() == root) { tracking_id = spawned.first; break; } } - const U8 attach_state = 0x10; // chest + const S32 point = (attach_point > 0) ? attach_point : 1; // default to chest + const U8 attach_state = (U8)ATTACHMENT_ID_FROM_STATE(point); for (auto& spawned : mSpawnedObjects) { if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 6cf927f5f5..35590c1765 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -183,11 +183,15 @@ class LLLocalMeshMgr : public LLSingleton // on client-only previews, which the sim delete path can't touch. void deletePreviewObject(LLViewerObject* obj); - // Attach/detach a rigged preview to the agent avatar (menu-driven, M4). Each - // acts on the preview linkset that owns `obj` (root or child). 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); + // 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); void detachPreviewFromAvatar(LLViewerObject* obj); bool isRiggedPreview(const LLViewerObject* obj) const; bool isPreviewAttached(const LLViewerObject* obj) const; diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 5f19238102..fe34f90155 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -6377,39 +6377,6 @@ bool enable_object_delete() return new_value; } -// Local mesh preview: wear/take-off a rigged preview on the agent avatar (M4). -// Only shown for a selected client-only rigged preview. -bool enable_attach_local_mesh() -{ - if (!LLLocalMeshMgr::instanceExists()) - { - return false; - } - LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); - return obj && LLLocalMeshMgr::getInstance()->isRiggedPreview(obj); -} - -void handle_attach_local_mesh() -{ - if (!LLLocalMeshMgr::instanceExists()) - { - return; - } - LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); - LLLocalMeshMgr* mgr = LLLocalMeshMgr::getInstance(); - if (obj && mgr->isRiggedPreview(obj)) - { - if (mgr->isPreviewAttached(obj)) - { - mgr->detachPreviewFromAvatar(obj); - } - else - { - mgr->attachPreviewToAvatar(obj); - } - } -} - class LLObjectsReturnPackage { public: @@ -7652,6 +7619,19 @@ class LLObjectAttachToAvatar : public view_listener_t if (selectedObject) { S32 index = userdata.asInteger(); + + // 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). + if (selectedObject->isLocalOnly() && LLLocalMeshMgr::instanceExists()) + { + LLLocalMeshMgr::getInstance()->attachPreviewToAvatar(selectedObject, index); + setObjectSelection(NULL); + return true; + } + LLViewerJointAttachment* attachment_point = NULL; if (index > 0) attachment_point = get_if_there(gAgentAvatarp->mAttachmentPoints, index, (LLViewerJointAttachment*)NULL); @@ -10742,7 +10722,6 @@ 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.AttachLocalMesh", boost::bind(&handle_attach_local_mesh)); 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"); @@ -10771,7 +10750,6 @@ 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.EnableAttachLocalMesh", boost::bind(&enable_attach_local_mesh)); enable.add("Object.EnableWear", boost::bind(&object_is_wearable)); enable.add("Object.EnableStandUp", boost::bind(&enable_object_stand_up)); diff --git a/indra/newview/skins/default/xui/en/menu_object.xml b/indra/newview/skins/default/xui/en/menu_object.xml index 4699d11626..abbae4d9c9 100644 --- a/indra/newview/skins/default/xui/en/menu_object.xml +++ b/indra/newview/skins/default/xui/en/menu_object.xml @@ -22,14 +22,6 @@ - - - - From cdce3fa1d0d1254962db47a4707c95c2a862c73e Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 16:34:25 -0400 Subject: [PATCH 27/58] Fix spurious LLSDXMLParser error on single-line documents LLSDSerialize::deserialize() hands the first line of a legacy document to LLSDXMLParser::parsePart(), then parses the rest. When the whole document fits in that first chunk (common for empty containers like ), reaching calls XML_StopParser(false), so XML_Parse returns XML_STATUS_ERROR even though the parse succeeded. parsePart() was the only parse path missing the !mGracefullStop guard that parse()/parseLines() use, so it logged "Unexpected XML parsing error at start" for what was a graceful stop. The parsed data was still correct; only the log was misleading. Add the guard (and gate on mEmitErrors to match the siblings), plus a regression test that fails when the guard is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/llcommon/llsdserialize_xml.cpp | 12 +++++++-- indra/llcommon/tests/llsdserialize_test.cpp | 28 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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: From 530a4a02575ededfb2cfdf6cb39a6a5ab1963f94 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 3 Jun 2026 17:48:47 -0400 Subject: [PATCH 28/58] Local mesh preview (M5): load + play a local animation on an animesh preview Builds on M5a (a local rigged mesh made an animated object via the standard Build > Features > "Animated Mesh" checkbox already works client-side -- the control avatar is created/cleaned up by existing machinery + the isLocalOnly gates). This adds playing a local animation on that control avatar. New LLLocalAnimMgr (lllocalanim.h/.cpp), the animation analog of LLLocalBitmap/ LLLocalMesh: * loadAnim() reads a .anim (already LLKeyframeMotion serialized form) or a .bvh (parsed via LLBVHLoader + serialize(), using the agent's joint aliases), and keeps the keyframe bytes under a client-only motion id. * playOnAvatar() createMotion()s on the animesh control avatar, deserialize()s the bytes (which also caches them in LLKeyframeDataCache so replays / a recreated control avatar resolve the id without an asset fetch), and startMotion()s -- replacing any local anim already playing on that avatar. * stopOnAvatar() stops the tracked motion. Menu: "Play Local Animation..." / "Stop Local Animation" on the object context menu, shown only for a client-only animesh root (on_visible). Play uses the standard FFLOAD_ANIM file picker (.anim/.bvh). The control avatar is held by LLPointer across the async picker and re-validated before playing. Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/CMakeLists.txt | 2 + indra/newview/lllocalanim.cpp | 205 ++++++++++++++++++ indra/newview/lllocalanim.h | 80 +++++++ indra/newview/llviewermenu.cpp | 59 +++++ .../skins/default/xui/en/menu_object.xml | 16 ++ 5 files changed, 362 insertions(+) create mode 100644 indra/newview/lllocalanim.cpp create mode 100644 indra/newview/lllocalanim.h diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 76f1caab2b..9aae19540f 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -514,6 +514,7 @@ set(viewer_SOURCE_FILES lllocalbitmaps.cpp lllocalgltfmaterials.cpp lllocalmesh.cpp + lllocalanim.cpp lllocationhistory.cpp lllocationinputctrl.cpp lllogchat.cpp @@ -1278,6 +1279,7 @@ set(viewer_HEADER_FILES lllocalbitmaps.h lllocalgltfmaterials.h lllocalmesh.h + lllocalanim.h lllocationhistory.h lllocationinputctrl.h lllogchat.h diff --git a/indra/newview/lllocalanim.cpp b/indra/newview/lllocalanim.cpp new file mode 100644 index 0000000000..29f428b49a --- /dev/null +++ b/indra/newview/lllocalanim.cpp @@ -0,0 +1,205 @@ +/** + * @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 "lldatapacker.h" +#include "lldir.h" +#include "llfile.h" +#include "llkeyframemotion.h" +#include "llstring.h" +#include "llvoavatar.h" +#include "llvoavatarself.h" // gAgentAvatarp, isAgentAvatarValid (BVH joint aliases) + +LLLocalAnimMgr::LLLocalAnimMgr() +{ +} + +LLLocalAnimMgr::~LLLocalAnimMgr() +{ + // Drop the keyframe data we cached globally for our local motions. + for (const auto& entry : mAnims) + { + LLKeyframeDataCache::removeKeyframeData(entry.first); + } + mAnims.clear(); +} + +LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) +{ + 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 LLUUID::null; + } + + const S64 file_size = infile.size(ec); + if (file_size <= 0 || ec) + { + LL_WARNS("LocalAnim") << "Empty or unreadable animation file: " << filename << LL_ENDL; + return LLUUID::null; + } + + 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 LLUUID::null; + } + 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. + std::string ext = gDirUtilp->getExtension(filename); + LLStringUtil::toLower(ext); + + std::vector keyframe; + if (ext == "anim") + { + 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(); + } + 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 LLUUID::null; + } + const U32 out_size = loader.getOutputSize(); + keyframe.resize(out_size); + LLDataPackerBinaryBuffer dp(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 LLUUID::null; + } + + if (keyframe.empty()) + { + LL_WARNS("LocalAnim") << "No animation data decoded from " << filename << LL_ENDL; + 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); + + const size_t bytes = anim.mData.size(); + mAnims[id] = std::move(anim); + + LL_INFOS("LocalAnim") << "Loaded local anim '" << mAnims[id].mShortName << "' (" + << bytes << " bytes) as " << id << LL_ENDL; + return id; +} + +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(); +} diff --git a/indra/newview/lllocalanim.h b/indra/newview/lllocalanim.h new file mode 100644 index 0000000000..b9d5b9f51d --- /dev/null +++ b/indra/newview/lllocalanim.h @@ -0,0 +1,80 @@ +/** + * @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) 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, so loading +// is just reading the bytes and deserializing them into a motion (which also caches +// the keyframe data globally, so any control avatar can play the id). + +#ifndef LL_LLLOCALANIM_H +#define LL_LLLOCALANIM_H + +#include "llsingleton.h" +#include "lluuid.h" + +#include +#include +#include + +class LLVOAvatar; + +// Owns loaded local animations and plays them on control avatars. +class LLLocalAnimMgr : public LLSingleton +{ + LLSINGLETON(LLLocalAnimMgr); + ~LLLocalAnimMgr(); + +public: + // Load a local .anim file into a client-only motion. Returns the motion id, or + // null on failure. The keyframe bytes are kept so the motion can be + // (re)deserialized onto any control avatar that plays 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::map mAnims; + + // Which local anim is currently playing on each control avatar (by avatar id), + // so a later play can replace it and "Stop" can end it. + std::map mPlaying; +}; + +#endif // LL_LLLOCALANIM_H diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index fe34f90155..2f202e9b0e 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -121,6 +121,8 @@ #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" @@ -685,6 +687,60 @@ class LLAdvancedLoadLocalMesh : public view_listener_t } }; +// Local animation (M5): play/stop a local .anim/.bvh on a local mesh that has been +// made an animated object (animesh). The animation runs on the linkset's control +// avatar, so these are only meaningful for a client-only animesh root. +static LLControlAvatar* get_local_animesh_control_avatar() +{ + LLViewerObject* obj = LLSelectMgr::getInstance()->getSelection()->getPrimaryObject(); + if (!obj || !obj->isLocalOnly()) + { + return nullptr; + } + LLViewerObject* root = obj->getRootEdit(); + return (root && root->isAnimatedObject()) ? root->getControlAvatar() : nullptr; +} + +bool enable_play_local_anim() +{ + return get_local_animesh_control_avatar() != nullptr; +} + +void handle_play_local_anim() +{ + LLControlAvatar* cav = get_local_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_local_animesh_control_avatar(); + if (cav && LLLocalAnimMgr::instanceExists()) + { + LLLocalAnimMgr::getInstance()->stopOnAvatar(cav); + } +} + class LLAdvancedToggleConsole : public view_listener_t { bool handleEvent(const LLSD& userdata) @@ -10722,6 +10778,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"); @@ -10750,6 +10808,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/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 @@ + + + + + + + + From b9d4877d2c4f1c2c59e2efb25084ba8962bec63a Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 4 Jun 2026 15:18:52 -0400 Subject: [PATCH 29/58] Local Assets (M6): unified floater for local mesh/anim/texture/material Add a single "Local Assets" floater with tabs for Mesh, Animations, Textures, and GLTF Materials. Load files from disk and preview them entirely client-side -- no asset-server upload -- as a working tool for 3D content creators. Mesh: Add/Delete, Rez/Derez in world, attach/detach at a chosen point (attach-point combo + right-click row menu), Select-in-world, a joint-position-override toggle, in-place hot-swap live reload (sculpt-id swap, no despawn), and a Status column (Rezzed / Attached: ). Animations: Add/Delete and Play/Stop on your own avatar or any selected animesh, with ~3s live reload. Textures & GLTF Materials: Add/Delete over the shared list. Reactive UI via boost::signals2 units-changed callbacks on all four local managers. Lazy per-account persistence (LLLocalAssetPaths): the working set's file paths reload at login as dimmed rows and decode on first use; each mesh's joint-override flag persists too. All user-visible strings are localized (panel/floater + strings.xml). Co-Authored-By: Claude Opus 4.8 (1M context) --- indra/newview/CMakeLists.txt | 4 + indra/newview/llfloaterlocalassets.cpp | 998 ++++++++++++++++++ indra/newview/llfloaterlocalassets.h | 44 + indra/newview/lllocalanim.cpp | 274 ++++- indra/newview/lllocalanim.h | 73 +- indra/newview/lllocalassetpaths.cpp | 253 +++++ indra/newview/lllocalassetpaths.h | 78 ++ indra/newview/lllocalbitmaps.cpp | 25 + indra/newview/lllocalbitmaps.h | 5 + indra/newview/lllocalgltfmaterials.cpp | 29 + indra/newview/lllocalgltfmaterials.h | 6 + indra/newview/lllocalmesh.cpp | 218 +++- indra/newview/lllocalmesh.h | 46 +- indra/newview/llselectmgr.cpp | 6 +- indra/newview/llstartup.cpp | 7 + indra/newview/llviewerfloaterreg.cpp | 2 + indra/newview/llviewermenu.cpp | 17 +- indra/newview/llviewermenu.h | 6 + .../default/xui/en/floater_local_assets.xml | 27 + .../skins/default/xui/en/menu_local_mesh.xml | 39 + .../skins/default/xui/en/menu_viewer.xml | 7 + .../default/xui/en/panel_local_asset_list.xml | 137 +++ .../newview/skins/default/xui/en/strings.xml | 3 + 23 files changed, 2242 insertions(+), 62 deletions(-) create mode 100644 indra/newview/llfloaterlocalassets.cpp create mode 100644 indra/newview/llfloaterlocalassets.h create mode 100644 indra/newview/lllocalassetpaths.cpp create mode 100644 indra/newview/lllocalassetpaths.h create mode 100644 indra/newview/skins/default/xui/en/floater_local_assets.xml create mode 100644 indra/newview/skins/default/xui/en/menu_local_mesh.xml create mode 100644 indra/newview/skins/default/xui/en/panel_local_asset_list.xml diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 9aae19540f..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 @@ -515,6 +516,7 @@ set(viewer_SOURCE_FILES lllocalgltfmaterials.cpp lllocalmesh.cpp lllocalanim.cpp + lllocalassetpaths.cpp lllocationhistory.cpp lllocationinputctrl.cpp lllogchat.cpp @@ -1146,6 +1148,7 @@ set(viewer_HEADER_FILES llfloaterlandholdings.h llfloaterlinkreplace.h llfloaterloadprefpreset.h + llfloaterlocalassets.h llfloatermap.h llfloatermarketplace.h llfloatermarketplacelistings.h @@ -1280,6 +1283,7 @@ set(viewer_HEADER_FILES lllocalgltfmaterials.h lllocalmesh.h lllocalanim.h + lllocalassetpaths.h lllocationhistory.h lllocationinputctrl.h lllogchat.h diff --git a/indra/newview/llfloaterlocalassets.cpp b/indra/newview/llfloaterlocalassets.cpp new file mode 100644 index 0000000000..4dd61897f9 --- /dev/null +++ b/indra/newview/llfloaterlocalassets.cpp @@ -0,0 +1,998 @@ +/** + * @file llfloaterlocalassets.cpp + * @brief Unified "Local Assets" floater 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 "llfloaterlocalassets.h" + +#include "llbutton.h" +#include "llcheckboxctrl.h" +#include "llcombobox.h" +#include "lldir.h" +#include "llfilepicker.h" +#include "llinventoryicon.h" +#include "llmenugl.h" +#include "llpanel.h" +#include "llscrolllistcolumn.h" +#include "llscrolllistctrl.h" +#include "llscrolllistitem.h" +#include "lltabcontainer.h" +#include "lluictrlfactory.h" +#include "llviewermenufile.h" // LLFilePickerReplyThread + +#include "llagentcamera.h" +#include "llcontrolavatar.h" +#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 "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(); + +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 + 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; + + // 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*/) {} + + 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* mDelBtn { nullptr }; + +private: + void appendUnloaded(); + void selectByPath(const std::string& path); + void onAddBtn(); + void onDelBtn(); + 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"); + mDelBtn = getChild("del_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)); + mDelBtn->setCommitCallback(boost::bind(&LLPanelLocalAssetBase::onDelBtn, 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(); +} + +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; +} + +void LLPanelLocalAssetBase::onSelectionChange() +{ + const bool has_selection = mList && !mList->getAllSelected().empty(); + if (mDelBtn) + { + mDelBtn->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::onDelBtn() +{ + 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 + } + if (entry.second.notNull()) + { + delUnit(entry.second); // 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. + 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 doDelete(const LLUUID& tracking_id); + 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; } + boost::signals2::connection connectChanged(const std::function& cb) override + { + return LLLocalMeshMgr::getInstance()->setUnitsChangedCallback(cb); + } + + 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.Delete", boost::bind(&LLPanelLocalMesh::doDelete, 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"); + c.width.relative_width = 1.f; + 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)); + 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(); + const bool can_attach = loaded && 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 works on a decoded unit or (auto-loading) an undecoded one; once it's + // in-world the same button becomes Derez. + mRezBtn->setEnabled(has_selection); + mRezBtn->setLabel(getString(spawned ? "derez_label" : "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()) + { + // Decoded: Rez, or Derez if already in-world. + if (LLLocalMeshMgr::getInstance()->getSpawnedRoot(id)) + { + doDerez(id); + } + else + { + doSpawn(id); + } + 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() +{ + doAttach(getSelectedID(), 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(); + LLViewerObject* root = mgr->getSpawnedRoot(tracking_id); + if (!root) + { + root = mgr->spawnInWorld(tracking_id); // not in-world yet: rez it, then wear it + } + if (root) + { + mgr->attachPreviewToAvatar(root, attach_point); // if it spawned, the 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::doDelete(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 in-world and point the camera at it. + LLSelectMgr::getInstance()->deselectAll(); + LLSelectMgr::getInstance()->selectObjectAndFamily(root); + gAgentCamera.setFocusOnAvatar(false, false); + gAgentCamera.setFocusGlobal(root->getPositionGlobal(), root->getID()); +} + +// ============================================================================ +// 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; } + boost::signals2::connection connectChanged(const std::function& cb) override + { + return LLLocalAnimMgr::getInstance()->setUnitsChangedCallback(cb); + } + + 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); + } +} + +// ============================================================================ +// Textures tab -- mirrors the texture-picker Local tab (Add/Delete only). +// ============================================================================ +class LLPanelLocalTexture final : public LLPanelLocalAssetBase +{ +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; } + boost::signals2::connection connectChanged(const std::function& cb) override + { + return LLLocalBitmapMgr::getInstance()->setUnitsChangedCallback(cb); + } +}; + +// ============================================================================ +// GLTF Materials tab -- one row per material (a .gltf can hold several). +// ============================================================================ +class LLPanelLocalMaterial final : public LLPanelLocalAssetBase +{ +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; + } + 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; } + boost::signals2::connection connectChanged(const std::function& cb) override + { + return LLLocalGLTFMaterialMgr::getInstance()->setUnitsChangedCallback(cb); + } +}; + +// 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->setName(name); + panel->buildFromFile(xml); + 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"); + + 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; +} diff --git a/indra/newview/llfloaterlocalassets.h b/indra/newview/llfloaterlocalassets.h new file mode 100644 index 0000000000..7394de3c7b --- /dev/null +++ b/indra/newview/llfloaterlocalassets.h @@ -0,0 +1,44 @@ +/** + * @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" + +class LLTabContainer; + +class LLFloaterLocalAssets final : public LLFloater +{ +public: + LLFloaterLocalAssets(const LLSD& key); + ~LLFloaterLocalAssets() override; + + bool postBuild() override; + +private: + LLTabContainer* mTabs { nullptr }; +}; + +#endif // LL_LLFLOATERLOCALASSETS_H diff --git a/indra/newview/lllocalanim.cpp b/indra/newview/lllocalanim.cpp index 29f428b49a..e2afe19082 100644 --- a/indra/newview/lllocalanim.cpp +++ b/indra/newview/lllocalanim.cpp @@ -27,20 +27,44 @@ #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) +#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) { @@ -49,7 +73,7 @@ LLLocalAnimMgr::~LLLocalAnimMgr() mAnims.clear(); } -LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) +bool LLLocalAnimMgr::decodeFile(const std::string& filename, std::vector& out_keyframe) const { std::error_code ec; LLFile infile; @@ -57,21 +81,21 @@ LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) if (!infile || ec) { LL_WARNS("LocalAnim") << "Can't open animation file: " << filename << LL_ENDL; - return LLUUID::null; + return false; } const S64 file_size = infile.size(ec); if (file_size <= 0 || ec) { LL_WARNS("LocalAnim") << "Empty or unreadable animation file: " << filename << LL_ENDL; - return LLUUID::null; + 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 LLUUID::null; + return false; } infile.close(); @@ -80,10 +104,9 @@ LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) std::string ext = gDirUtilp->getExtension(filename); LLStringUtil::toLower(ext); - std::vector keyframe; if (ext == "anim") { - keyframe = std::move(data); + out_keyframe = std::move(data); } else if (ext == "bvh") { @@ -100,22 +123,32 @@ LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) { LL_WARNS("LocalAnim") << "BVH parse failed (status " << load_status << ", line " << line_number << "): " << filename << LL_ENDL; - return LLUUID::null; + return false; } const U32 out_size = loader.getOutputSize(); - keyframe.resize(out_size); - LLDataPackerBinaryBuffer dp(keyframe.data(), (S32)out_size); + 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 LLUUID::null; + return false; } - if (keyframe.empty()) + 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) +{ + std::vector keyframe; + if (!decodeFile(filename, keyframe)) + { return LLUUID::null; } @@ -126,15 +159,211 @@ LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) anim.mFilename = filename; anim.mShortName = gDirUtilp->getBaseFileName(filename, true /* strip extension */); anim.mData = std::move(keyframe); + 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); + } +} + +void 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; + } + + // Purge the stale parsed motion instance so createMotion() yields a fresh one + // that deserializes the new bytes. + av->stopMotion(anim_id, true); + av->removeMotion(anim_id); + + LLKeyframeMotion* motionp = dynamic_cast(av->createMotion(anim_id)); + if (!motionp) + { + return; + } + LLDataPackerBinaryBuffer dp(iter->second.mData.data(), (S32)iter->second.mData.size()); + if (motionp->deserialize(dp, anim_id, false)) + { + av->startMotion(anim_id); + } +} + +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; + } + anim.mLastModified = mtime; + + std::vector fresh; + if (!decodeFile(anim.mFilename, fresh)) + { + continue; // keep last good data; retry on the next mtime change + } + 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); + for (const auto& play : mPlaying) + { + if (play.second == id) + { + reapplyToAvatar(play.first, id); + } + } + 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); @@ -203,3 +432,24 @@ 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 index b9d5b9f51d..e7e11fdfc8 100644 --- a/indra/newview/lllocalanim.h +++ b/indra/newview/lllocalanim.h @@ -23,23 +23,29 @@ */ // Local Animation is the animation analog of Local Mesh (lllocalmesh.h): it loads -// a Second Life internal animation file (.anim) 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, so loading -// is just reading the bytes and deserializing them into a motion (which also caches -// the keyframe data globally, so any control avatar can play the id). +// 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. @@ -49,9 +55,29 @@ class LLLocalAnimMgr : public LLSingleton ~LLLocalAnimMgr(); public: - // Load a local .anim file into a client-only motion. Returns the motion id, or - // null on failure. The keyframe bytes are kept so the motion can be - // (re)deserialized onto any control avatar that plays it. + // --- 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), @@ -66,15 +92,36 @@ class LLLocalAnimMgr : public LLSingleton private: struct LocalAnim { - std::string mFilename; - std::string mShortName; - std::vector mData; // raw .anim bytes == LLKeyframeMotion serialized form + 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 and "Stop" can end it. + // 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). + bool decodeFile(const std::string& filename, std::vector& out_keyframe) const; + // Re-deserialize fresh bytes onto an avatar already playing this id (live reload). + void 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..4bb45c4658 --- /dev/null +++ b/indra/newview/lllocalassetpaths.cpp @@ -0,0 +1,253 @@ +/** + * @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() +{ + if (mWatching) + { + return; // once per session + } + mWatching = true; + + // 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(); + } + + // 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))); +} diff --git a/indra/newview/lllocalassetpaths.h b/indra/newview/lllocalassetpaths.h new file mode 100644 index 0000000000..6ba332816a --- /dev/null +++ b/indra/newview/lllocalassetpaths.h @@ -0,0 +1,78 @@ +/** + * @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 + 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..4f0227603e 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,6 +1101,10 @@ LLUUID LLLocalBitmapMgr::addUnit(const std::string& filename) mTimer.stopTimer(); LLUUID tracking_id = addUnitInternal(filename); mTimer.startTimer(); + if (tracking_id.notNull()) + { + mUnitsChangedSignal(); + } return tracking_id; } @@ -1206,6 +1214,23 @@ 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) + { + out.push_back(unit->getFilename()); + } + return out; } LLUUID LLLocalBitmapMgr::getTrackingID(const LLUUID& world_id) const diff --git a/indra/newview/lllocalbitmaps.h b/indra/newview/lllocalbitmaps.h index cd526e50cb..07102a04d2 100644 --- a/indra/newview/lllocalbitmaps.h +++ b/indra/newview/lllocalbitmaps.h @@ -147,10 +147,14 @@ class LLLocalBitmapMgr : public LLSingleton LLUUID getWorldID(const LLUUID &tracking_id) const; 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 +163,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..5aba5a778e 100644 --- a/indra/newview/lllocalgltfmaterials.cpp +++ b/indra/newview/lllocalgltfmaterials.cpp @@ -327,6 +327,10 @@ S32 LLLocalGLTFMaterialMgr::addUnit(const std::vector& filenames) iter++; } mTimer.startTimer(); + if (add_count > 0) + { + mUnitsChangedSignal(); + } return add_count; } @@ -341,6 +345,10 @@ S32 LLLocalGLTFMaterialMgr::addUnit(const std::string& filename, LLUUID& outID) mTimer.stopTimer(); S32 res = addUnitInternal(filename, outID); mTimer.startTimer(); + if (res > 0) + { + mUnitsChangedSignal(); + } return res; } @@ -412,6 +420,27 @@ 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) + { + 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) diff --git a/indra/newview/lllocalgltfmaterials.h b/indra/newview/lllocalgltfmaterials.h index 5a68681a9a..ecfa208316 100644 --- a/indra/newview/lllocalgltfmaterials.h +++ b/indra/newview/lllocalgltfmaterials.h @@ -30,6 +30,7 @@ #include "lleventtimer.h" #include "llpointer.h" #include "llgltfmateriallist.h" +#include #include class LLScrollListCtrl; @@ -111,13 +112,18 @@ class LLLocalGLTFMaterialMgr : public LLSingleton LLUUID getWorldID(LLUUID tracking_id); bool isLocal(LLUUID world_id); void getFilenameAndIndex(LLUUID tracking_id, std::string &filename, S32 &index); + 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 index eac83038ca..ea3813a02e 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -52,6 +52,8 @@ #include "llviewercontrol.h" #include "llviewerobjectlist.h" #include "llviewerregion.h" +#include "lltrans.h" +#include "llviewerjointattachment.h" #include "llvoavatarself.h" #include "llvovolume.h" #include "pipeline.h" @@ -341,12 +343,13 @@ namespace /*=======================================*/ /* LLLocalMesh: unit class */ /*=======================================*/ -LLLocalMesh::LLLocalMesh(std::string filename) +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) @@ -580,14 +583,16 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) if (!mdl->mSkinInfo.mJointNames.empty()) { - // Owned copy bound to this part's world id. include_joints=false - // strips the alt-inverse-bind matrices (joint-position overrides) + - // pelvis offset while keeping joint names, inverse bind, and bind - // shape -- everything skinning needs. This mirrors the mesh-upload - // preview's 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. - LLSD sd = mdl->mSkinInfo.asLLSD(false, false); + // 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()); } @@ -688,6 +693,20 @@ void LLLocalMesh::finishReload(bool ok) } } +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 */ /*=======================================*/ @@ -794,9 +813,9 @@ LLVOAvatar* LLLocalMeshMgr::getPreviewAvatar() return mPreviewAvatar.get(); } -LLUUID LLLocalMeshMgr::addUnit(const std::string& filename) +LLUUID LLLocalMeshMgr::addUnit(const std::string& filename, bool include_joints) { - return addUnitInternal(filename); + return addUnitInternal(filename, include_joints); } bool LLLocalMeshMgr::addUnit(const std::vector& filenames) @@ -812,9 +831,9 @@ bool LLLocalMeshMgr::addUnit(const std::vector& filenames) return any; } -LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename) +LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename, bool include_joints) { - LLLocalMesh* unit = new LLLocalMesh(filename); + LLLocalMesh* unit = new LLLocalMesh(filename, include_joints); if (unit->isFailed()) { // Immediate failure (bad extension / no avatar / missing file). @@ -826,6 +845,7 @@ LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename) // 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 @@ -854,6 +874,7 @@ void LLLocalMeshMgr::delUnit(LLUUID tracking_id) { mTimer.stopTimer(); // nothing left to watch } + mUnitsChangedSignal(); } LLUUID LLLocalMeshMgr::getUnitID(const std::string& filename) const @@ -928,7 +949,18 @@ LLLocalMesh* LLLocalMeshMgr::getUnit(const LLUUID& tracking_id) const return nullptr; } -void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) +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) { @@ -936,8 +968,8 @@ void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) } // Map the object (linkset root or child) back to the unit that owns it, then - // drop the whole unit -- this despawns the entire linkset and stops live - // reload, so the deleted preview stays deleted. + // derez that unit's linkset. 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. LLUUID tracking_id; for (const auto& spawned : mSpawnedObjects) { @@ -948,12 +980,22 @@ void LLLocalMeshMgr::deletePreviewObject(LLViewerObject* obj) } } + despawn(tracking_id); +} + +void LLLocalMeshMgr::despawn(const LLUUID& tracking_id) +{ if (tracking_id.notNull()) { - delUnit(tracking_id); + 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) @@ -1150,6 +1192,8 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) LLVector3 base; LLQuaternion root_rot; bool had_prev = false; + bool was_attached = false; + S32 attach_point = 0; for (const auto& spawned : mSpawnedObjects) { if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) @@ -1157,6 +1201,12 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) base = spawned.second->getPositionAgent(); root_rot = spawned.second->getRotation(); had_prev = true; + if (spawned.second->isAttachment()) + { + // Preview was worn: re-wear the rebuilt linkset at the same point. + was_attached = true; + attach_point = ATTACHMENT_ID_FROM_STATE(spawned.second->getAttachmentState()); + } break; // the first entry for a tracking id is the linkset root } } @@ -1226,12 +1276,95 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) { root->setRotation(root_rot); // restore the linkset orientation across reload } + if (root && was_attached) + { + attachPreviewToAvatar(root, attach_point); // restore the attachment across re-spawn + } + mUnitsChangedSignal(); // rezzed state changed LL_INFOS("LocalMesh") << "Spawned local mesh '" << unit->getShortName() << "' as " << unit->getNumParts() << " prim(s), " << unit->getNumFaces() << " faces" << LL_ENDL; return root; } +bool LLLocalMeshMgr::hotSwapInWorld(const LLUUID& tracking_id) +{ + LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid()) + { + return false; + } + const std::vector& parts = unit->getParts(); + + // The existing spawned prims for this unit, in spawn order (root first). + std::vector objs; + for (const auto& spawned : mSpawnedObjects) + { + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) + { + objs.push_back(spawned.second.get()); + } + } + + // Only a structurally identical linkset can be swapped 1:1 in place; a changed + // prim count needs a real re-spawn (to add/remove prims). + if (objs.empty() || objs.size() != parts.size()) + { + 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 -> the + // attachment, selection and transform are all preserved. + for (size_t i = 0; i < parts.size(); ++i) + { + LLVOVolume* vol = dynamic_cast(objs[i]); + if (!vol) + { + return false; + } + vol->setScale(parts[i].mScale, false); + applyPartGeometry(vol, parts[i]); + } + + LL_INFOS("LocalMesh") << "Hot-swapped local mesh '" << unit->getShortName() << "' (" + << parts.size() << " prim(s)) in place" << LL_ENDL; + return true; +} + +LLViewerObject* LLLocalMeshMgr::getSpawnedRoot(const LLUUID& tracking_id) const +{ + for (const auto& spawned : mSpawnedObjects) + { + // The first live entry pushed for a tracking id is the linkset root. + if (spawned.first == tracking_id && spawned.second.notNull() && !spawned.second->isDead()) + { + return spawned.second; + } + } + return nullptr; +} + +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 @@ -1287,9 +1420,17 @@ void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scen if (assembled) { logUnit("Reloaded", unit); - // Replace the existing linkset with the new geometry; spawnInWorld - // preserves the root transform across the swap. - spawnInWorld(tracking_id); + // 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)) + { + spawnInWorld(tracking_id); + } + } } return; } @@ -1361,6 +1502,7 @@ void LLLocalMeshMgr::despawnUnit(const LLUUID& tracking_id) ++iter; } } + mUnitsChangedSignal(); // rezzed state changed } void LLLocalMeshMgr::feedScrollList(LLScrollListCtrl* ctrl) @@ -1374,6 +1516,8 @@ void LLLocalMeshMgr::feedScrollList(LLScrollListCtrl* ctrl) for (LLLocalMesh* unit : mMeshList) { + LLViewerObject* root = getSpawnedRoot(unit->getTrackingID()); + LLSD element; element["columns"][0]["column"] = "icon"; element["columns"][0]["type"] = "icon"; @@ -1382,6 +1526,40 @@ void LLLocalMeshMgr::feedScrollList(LLScrollListCtrl* ctrl) 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): in-world state + attachment point. + std::string status; + if (root) + { + 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; + status = LLTrans::getString("LocalAssetAttached", args); + } + else + { + status = LLTrans::getString("LocalAssetRezzed"); + } + } + element["columns"][2]["column"] = "status"; + element["columns"][2]["type"] = "text"; + element["columns"][2]["value"] = status; LLSD data; data["id"] = unit->getTrackingID(); diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 35590c1765..9625ec4ad3 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -43,6 +43,7 @@ #include "lluuid.h" #include "v3math.h" +#include #include #include #include @@ -76,7 +77,7 @@ struct LLLocalMeshPart class LLLocalMesh { public: - LLLocalMesh(std::string filename); + LLLocalMesh(std::string filename, bool include_joints = false); ~LLLocalMesh(); std::string getFilename() const { return mFilename; } @@ -120,6 +121,7 @@ class LLLocalMesh // 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; } @@ -129,6 +131,7 @@ class LLLocalMesh EFormat mFormat; EState mState; bool mSpawnWhenReady; + bool mIncludeJointPositions = false; // bake joint-position overrides into the skin std::filesystem::file_time_type mLastModified; // for live reload (M3) @@ -164,24 +167,36 @@ class LLLocalMeshMgr : public LLSingleton ~LLLocalMeshMgr(); public: - LLUUID addUnit(const std::string& filename); + 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; + // 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; - // Delete the preview linkset that owns this object (and the loaded unit, so a - // later file save does not respawn it). Lets the standard Delete key/menu work - // on client-only previews, which the sim delete path can't touch. - void deletePreviewObject(LLViewerObject* obj); + // Despawn the in-world preview 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 the linkset that owns `obj`, letting the standard + // in-world Delete key/menu "derez" client-only previews (which the sim delete + // path can't touch) without dropping the file from the list. despawn() does the + // same by tracking id. To remove the file from the list entirely, use delUnit(). + void despawnPreviewObject(LLViewerObject* obj); + void despawn(const LLUUID& tracking_id); // 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 @@ -203,8 +218,17 @@ class LLLocalMeshMgr : public LLSingleton // Convenience: load each file and spawn it in-world once it finishes loading. void addAndSpawn(const std::vector& filenames); + // The spawned linkset root for a loaded unit, or null if it isn't in-world. + // Lets the UI act on an existing preview (e.g. attach it) without re-rezzing. + LLViewerObject* getSpawnedRoot(const LLUUID& tracking_id) 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); @@ -228,9 +252,15 @@ class LLLocalMeshMgr : public LLSingleton void despawnObjectsInRegion(LLViewerRegion* regionp); private: - LLUUID addUnitInternal(const std::string& filename); + LLUUID addUnitInternal(const std::string& filename, bool include_joints = false); void despawnUnit(const LLUUID& tracking_id); + // Reload an already-spawned linkset 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 spawnInWorld). + bool hotSwapInWorld(const LLUUID& tracking_id); + // 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. @@ -251,6 +281,8 @@ class LLLocalMeshMgr : public LLSingleton // for a tracking id is the linkset root. std::vector > > mSpawnedObjects; + boost::signals2::signal mUnitsChangedSignal; // add/remove/spawn/despawn + LLLocalMeshTimer mTimer; // drives live-reload polling LLPointer mPreviewAvatar; // skeleton for joint resolution (never the agent) }; diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index c661620bc1..4074364eee 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -187,8 +187,10 @@ static void deleteLocalPreviewSelection() for (LLPointer& obj : objects) { - // The first part of a linkset drops the whole unit; later parts no-op. - mgr->deletePreviewObject(obj.get()); + // 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()); } } // 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/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/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 2f202e9b0e..cb917cb8cd 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -687,13 +687,14 @@ class LLAdvancedLoadLocalMesh : public view_listener_t } }; -// Local animation (M5): play/stop a local .anim/.bvh on a local mesh that has been -// made an animated object (animesh). The animation runs on the linkset's control -// avatar, so these are only meaningful for a client-only animesh root. -static LLControlAvatar* get_local_animesh_control_avatar() +// 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 || !obj->isLocalOnly()) + if (!obj) { return nullptr; } @@ -703,12 +704,12 @@ static LLControlAvatar* get_local_animesh_control_avatar() bool enable_play_local_anim() { - return get_local_animesh_control_avatar() != nullptr; + return get_selected_animesh_control_avatar() != nullptr; } void handle_play_local_anim() { - LLControlAvatar* cav = get_local_animesh_control_avatar(); + LLControlAvatar* cav = get_selected_animesh_control_avatar(); if (!cav) { return; @@ -734,7 +735,7 @@ void handle_play_local_anim() void handle_stop_local_anim() { - LLControlAvatar* cav = get_local_animesh_control_avatar(); + LLControlAvatar* cav = get_selected_animesh_control_avatar(); if (cav && LLLocalAnimMgr::instanceExists()) { LLLocalAnimMgr::getInstance()->stopOnAvatar(cav); 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/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..768dca41ba --- /dev/null +++ b/indra/newview/skins/default/xui/en/floater_local_assets.xml @@ -0,0 +1,27 @@ + + + Mesh + 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..10a5ce458f --- /dev/null +++ b/indra/newview/skins/default/xui/en/menu_local_mesh.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml index 2f2863ede1..e5afd83341 100644 --- a/indra/newview/skins/default/xui/en/menu_viewer.xml +++ b/indra/newview/skins/default/xui/en/menu_viewer.xml @@ -60,6 +60,13 @@ function="Floater.ToggleOrBringToFront" parameter="experiences"/> + + + + + Rez in World + Derez + Name + Status + My Avatar + Selected Object + + + + +