diff --git a/doc/mesh/images/blender-gltf-export.png b/doc/mesh/images/blender-gltf-export.png new file mode 100644 index 0000000000..60fdb53aec Binary files /dev/null and b/doc/mesh/images/blender-gltf-export.png differ diff --git a/doc/mesh/images/blender-outliner.png b/doc/mesh/images/blender-outliner.png new file mode 100644 index 0000000000..7a57cea153 Binary files /dev/null and b/doc/mesh/images/blender-outliner.png differ diff --git a/doc/mesh/images/uploader-pivot.png b/doc/mesh/images/uploader-pivot.png new file mode 100644 index 0000000000..a609c185af Binary files /dev/null and b/doc/mesh/images/uploader-pivot.png differ diff --git a/doc/mesh/multi-object-single-file.md b/doc/mesh/multi-object-single-file.md new file mode 100644 index 0000000000..fcf92d8676 --- /dev/null +++ b/doc/mesh/multi-object-single-file.md @@ -0,0 +1,81 @@ +# Multi-Object Single-File Blender Exports + +The example throughout is a robot arm built as a linkset of ten parts: a base, a turret, an upper arm, a forearm, two wrist joints, a flange and three fingers. Each part has its own levels of detail (LOD) and a physics shape, and the whole arm uploads from one file. + +## How the uploader reads names + +Second Life draws each part with up to four levels of detail (LOD): High up close, then Medium, Low and Lowest further away, plus a physics shape for collisions. The uploader matches each part to its LOD models and physics shape by name: the part's name plus a suffix that says which slot it fills. + +| Slot in the uploader | Suffix | Example | +| --- | --- | --- | +| High (LOD3) | none | `A1_turret` | +| Medium (LOD2) | `_LOD2` | `A1_turret_LOD2` | +| Low (LOD1) | `_LOD1` | `A1_turret_LOD1` | +| Lowest (LOD0) | `_LOD0` | `A1_turret_LOD0` | +| Physics | `_PHYS` | `A1_turret_PHYS` | + +Every part and all its LOD models go in one `.glb`. You pick that one file, and the uploader puts each LOD model and physics shape into its part's slot. Any LOD you leave out is generated from the part. + +- Suffixes are upper case and go at the very end of the name. +- Don't use `_LOD` or `_PHYS` anywhere else in a name. +- No two parts may share a name. + +## Setting up the file + +Keep every part and its LOD models in one `.blend`, export the whole scene to one `.glb`, and pick that file in the uploader. Only the parts are uploaded; their `_LOD2`, `_LOD1`, `_LOD0` and `_PHYS` objects fill the parts' slots. + +The arm's Blender file keeps them in two collections: + +| Collection | Objects | +| --- | --- | +| `TelvanniArm` | the 10 parts: `A0_base`, `A1_turret` … `A7_finger_2` | +| `LODs` (hidden) | each part's LOD models and physics shape: `A1_turret_LOD2`, `A1_turret_LOD1`, `A1_turret_LOD0`, `A1_turret_PHYS`, and the same for every part | + +### Object names, not mesh names + +Each row in the Outliner is two things: the **object** (orange triangle, such as `A1_turret_LOD1`) and, under it, its **mesh data** (green triangle). The uploader reads only the object name. The mesh data is never read, so give it a plain label that can't be mistaken for one. The arm's are `Arm Turret`, `Arm Turret medium`, `Arm Turret low`, `Arm Turret lowest` and `Arm Turret collision`. + +In the screenshot below, the parts are parented in a chain under `TelvanniArm`, each holding its mesh (`Arm Base`, `Arm Turret`, `Upper Arm` …). The hidden `LODs` collection holds every LOD object; `A1_turret_LOD1` holds the mesh `Arm Turret low`. + +![The arm in Blender's Outliner: objects (orange) holding mesh data (green), the LOD models in the hidden LODs collection](images/blender-outliner.png) + +1. Name each part's object plainly (`A1_turret`), and each of its LOD objects with the suffix at the very end (`A1_turret_LOD1`). Delete Blender's `.001` endings. +2. Place each LOD model exactly over its part, with the same bounding box. +3. Export the whole scene with **Limit to** off, so the hidden LOD objects go in too. +4. Pick the `.glb` in **Build > Upload > Model**. The LOD dropdowns read **Load from file**, and the **Log** tab lists the LOD models taken from the file. + +- **Each LOD needs every part.** If only some parts have a `_LOD1`, the Low LOD is generated for all of them, and the log says so. +- **Physics may be partial.** A part with no `_PHYS` gets the uploader's usual physics. +- **A suffixed object needs its part.** `A1_turret_LOD1` with no `A1_turret` in the file uploads as a part of its own. +- Each LOD model needs the same material slots as its part. + +## glTF export settings + +Use **File > Export > glTF 2.0** with these settings; anything not listed can stay at its default. + +| Panel | Setting | Value | +| --- | --- | --- | +| Format | Format | glTF Binary (`.glb`) | +| Include > Limit to | all four | off, so the hidden LOD objects go in too | +| Include > Data | Cameras, Punctual Lights | off | +| Transform | +Y Up | on | +| Data > Mesh | Apply Modifiers | on | +| Data > Mesh | UVs, Normals | on | +| Data > Material | Materials | Export | +| Data > Compression | Draco Compression, Meshopt Compression | off | +| Animation | Animation | off | + +![Blender's glTF 2.0 export settings for a single-file upload](images/blender-gltf-export.png) + +Apply rotation and scale first (Ctrl+A), so the exported shape matches what you see. + +## Common mistakes + +Where each part turns in-world is set by its origin; that has [a guide of its own](object-origins.md). + +| You see | Likely cause | +| --- | --- | +| `A1_turret_LOD2` uploads as a separate part | its part is missing or misspelt, or the name ends in `.001` | +| An LOD you made is generated anyway | not every part has that LOD | +| An LOD looks stretched in-world | its bounding box differs from its part's | +| An LOD is missing from the upload | Limit to was on when exporting, so the hidden LOD objects were left out | diff --git a/doc/mesh/object-origins.md b/doc/mesh/object-origins.md new file mode 100644 index 0000000000..5a78ecb371 --- /dev/null +++ b/doc/mesh/object-origins.md @@ -0,0 +1,61 @@ +# Object Origins for Prim-Animated Doors, Hinges and Joints + +## Why the origin matters + +A prim in Second Life turns about its own position, and a mesh prim's position is the object origin it was uploaded with. Put the origin on the hinge, and a script that turns the door with `llSetLocalRot` swings it on its hinge. Leave it in the middle, and the door spins about its centre. + +Until now the uploader always moved the origin to the middle of the object's bounding box. Doors were built with an invisible extra half, or a script had to move the prim as it turned it. The uploader's **Pivot** option keeps the origin you set in Blender instead. + +## Setting the origin in Blender + +1. Apply rotation and scale first: select the object, **Ctrl+A > Rotation & Scale**. +2. Put the 3D cursor on the hinge or joint: in Edit Mode select the vertices along the hinge line, then **Shift+S > Cursor to Selected**. +3. Back in Object Mode, **Object > Set Origin > Origin to 3D Cursor**. +4. Check it: rotate the object (R, then an axis key). It should turn about the hinge, and the orange dot should sit on it. + +- **The origin must lie inside or on the object's bounding box.** The uploader stores it as a point within the box, so an origin outside is moved back to the centre. +- **Turn about a local axis.** Model the part so the hinge runs along X, Y or Z, so a script needs only one angle. +- Each part of a linkset has its own origin. An arm needs one per joint: the turret on its vertical axis, each limb on its elbow. + +## The uploader's Pivot + +The **Pivot** row is on the **Upload options** tab. It has three modes, X/Y/Z spinners beside them, and a status line under the spinners. + +![The Upload options tab: Pivot on From file, the spinners showing the upper arm's origin in percent, and the Origin arrows drawn on the preview at its elbow](images/uploader-pivot.png) + +| Mode | The origin goes | Works for | +| --- | --- | --- | +| From file (default) | where Blender's origin was, for each object | one object or many | +| Centre | to the middle of the bounding box, as uploads always did | one object or many | +| Custom | where you type it in the spinners | a single object only | + +- The spinners show the pivot along the object's own axes, in **percent** of its half size (−100 to 100, 0 = centre) or in **metres**; the dropdown beside them switches the unit. +- They are editable only in Custom, which starts from the pivot shown when you switch to it. +- Tick **Origin** under the preview to see the pivot: coloured arrows (X red, Y green, Z blue) drawn at each object's pivot. +- In a multi-object upload each part keeps its own origin, and its position in the linkset moves with it, so every part lands where it sat in Blender. + +The status line says when the pivot is held at the centre, or when the collision stays centred: + +| Status | Why | Fix | +| --- | --- | --- | +| Rigged mesh | rigged mesh is placed by its skeleton | none needed | +| Physics not analyzed | the sim fits a mesh physics shape to the box until it's analyzed | click **Analyze** on the Physics tab | +| No physics shape | the part moves to its pivot, but collides with a default hull centred on itself | fine for phantom parts or physics type None; otherwise add a physics shape and analyze it | + +## Examples and common mistakes + +| Part | Where the origin goes | Turns about | +| --- | --- | --- | +| Door | on the hinge edge, halfway up | its vertical axis | +| Chest lid | on the back edge, along the hinge | the hinge's axis | +| Arm turret | on the base's centre line | its vertical axis | +| Arm limb | in the joint at the limb's near end | the joint's axis | + +A limb's joint is often at the very end of the part. That's fine: the origin may sit on the box's edge, just not outside it. + +| You see | Likely cause | +| --- | --- | +| The part spins about its middle | the pivot is on Centre, the origin lies outside the box, or the physics isn't analyzed | +| The spinners read 0 0 0 on From file | Blender's origin was at the middle, or outside the box | +| The part rotates about the wrong axis | rotation wasn't applied before export | +| Custom is greyed out | the upload has several objects; Custom is for a single one | diff --git a/indra/llprimitive/lldaeloader.cpp b/indra/llprimitive/lldaeloader.cpp index 2bb02c6c13..f978a955f8 100644 --- a/indra/llprimitive/lldaeloader.cpp +++ b/indra/llprimitive/lldaeloader.cpp @@ -2131,7 +2131,7 @@ void LLDAELoader::processElement( daeElement* element, bool& badElement, DAE* da if (model->mLabel.empty()) { - label = getLodlessLabel(instance_geo); + label = mLod == LLModel::LOD_HIGH ? getElementLabel(instance_geo) : getLodlessLabel(instance_geo); // Mesh uploader LODs by name llassert(!label.empty()); @@ -2146,7 +2146,11 @@ void LLDAELoader::processElement( daeElement* element, bool& badElement, DAE* da { // Don't change model's name if possible, it will play havoc with scenes that already use said model. size_t ext_pos = getSuffixPosition(model->mLabel); - if (ext_pos != std::string::npos) + if (mLod == LLModel::LOD_HIGH) + { + label = model->mLabel; // Mesh uploader LODs by name: the instance is named as its model is + } + else if (ext_pos != std::string::npos) { label = model->mLabel.substr(0, ext_pos); } @@ -2512,7 +2516,8 @@ bool LLDAELoader::loadModelsFromDomMesh(domMesh* mesh, std::vector& mo LLModel* ret = new LLModel(volume_params, 0.f); - std::string model_name = getLodlessLabel(mesh); + // Mesh uploader LODs by name: the high LOD's file keeps its suffixes for the preview to sort out + std::string model_name = mLod == LLModel::LOD_HIGH ? getElementLabel(mesh) : getLodlessLabel(mesh); ret->mLabel = model_name + lod_suffix[mLod]; llassert(!ret->mLabel.empty()); diff --git a/indra/llprimitive/llmodel.cpp b/indra/llprimitive/llmodel.cpp index 9521c82269..c0ec5d69dd 100644 --- a/indra/llprimitive/llmodel.cpp +++ b/indra/llprimitive/llmodel.cpp @@ -627,7 +627,8 @@ LLSD LLModel::writeModel( bool lock_scale_if_joint_position, EWriteModelMode write_mode, bool as_slm, - int submodel_id) + int submodel_id, + const LLVector3& position_offset) // Mesh uploader pivot { LLSD mdl; @@ -665,6 +666,15 @@ LLSD LLModel::writeModel( model[LLModel::LOD_PHYSICS] = NULL; } + // Mesh uploader pivot: the sim keeps analyzed hulls in their domain but fits the bounding hull to the box + if (!decomp.mHull.empty() && mdl.has("physics_convex")) + { + LLSD& convex = mdl["physics_convex"]; + convex["Min"] = (LLVector3(convex["Min"]) + position_offset).getValue(); + convex["Max"] = (LLVector3(convex["Max"]) + position_offset).getValue(); + } + // + if (as_slm) { //save material list names for (U32 i = 0; i < high->mMaterialList.size(); ++i) @@ -813,8 +823,10 @@ LLSD LLModel::writeModel( } //write out face data - mdl[model_names[idx]][i]["PositionDomain"]["Min"] = min_pos.getValue(); - mdl[model_names[idx]][i]["PositionDomain"]["Max"] = max_pos.getValue(); + // Mesh uploader pivot: the quantized positions stay, only their domain moves + mdl[model_names[idx]][i]["PositionDomain"]["Min"] = (min_pos + position_offset).getValue(); + mdl[model_names[idx]][i]["PositionDomain"]["Max"] = (max_pos + position_offset).getValue(); + // mdl[model_names[idx]][i]["NormalizedScale"] = face.mNormalizedScale.getValue(); mdl[model_names[idx]][i]["Position"] = verts; diff --git a/indra/llprimitive/llmodel.h b/indra/llprimitive/llmodel.h index 55adbe7412..99e6f93c60 100644 --- a/indra/llprimitive/llmodel.h +++ b/indra/llprimitive/llmodel.h @@ -186,7 +186,8 @@ class alignas(16) LLModel : public LLVolume bool lock_scale_if_joint_position, EWriteModelMode write_mode = WRITE_BINARY, bool as_slm = false, - int submodel_id = 0); + int submodel_id = 0, + const LLVector3& position_offset = LLVector3::zero); // Mesh uploader pivot: shifts the stored domains off the prim's origin static LLSD writeModelToStream( std::ostream& ostr, diff --git a/indra/newview/gltf/llgltfloader.cpp b/indra/newview/gltf/llgltfloader.cpp index cb5d97ae9d..304d89b8b4 100644 --- a/indra/newview/gltf/llgltfloader.cpp +++ b/indra/newview/gltf/llgltfloader.cpp @@ -431,7 +431,8 @@ void LLGLTFLoader::processNodeHierarchy(S32 node_idx, std::map // Potentially multiple nodes can reuse the same mesh and Collada used // node name instead of mesh name, so for consistency use node name if // avaliable, node index otherwise. - std::string base_name = getLodlessLabel(node); + // Mesh uploader LODs by name: the high LOD's file keeps its suffixes for the preview to sort out + std::string base_name = mLod == LLModel::LOD_HIGH ? node.mName : getLodlessLabel(node); if (base_name.empty()) { base_name = "node_" + std::to_string(node_idx); diff --git a/indra/newview/llfloatermodelpreview.cpp b/indra/newview/llfloatermodelpreview.cpp index 4057890113..ef5d78cab4 100644 --- a/indra/newview/llfloatermodelpreview.cpp +++ b/indra/newview/llfloatermodelpreview.cpp @@ -31,6 +31,8 @@ #include "llfloatermodelpreview.h" +#include "llradiogroup.h" // Mesh uploader pivot + #include "llfilepicker.h" #include "llimagebmp.h" #include "llimagetga.h" @@ -191,6 +193,16 @@ bool LLFloaterModelPreview::postBuild() childSetCommitCallback("import_scale", onImportScaleCommit, this); childSetCommitCallback("pelvis_offset", onPelvisOffsetCommit, this); + // Mesh uploader pivot + for (const char* axis : { "pivot_x", "pivot_y", "pivot_z" }) + { + getChild(axis)->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotCommit, this)); + } + childSetValue("pivot_units", "percent"); + getChild("pivot_units")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotUnitsCommit, this)); + childSetValue("pivot_mode", "file"); + getChild("pivot_mode")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotModeCommit, this)); + // getChild("description_form")->setKeystrokeCallback(boost::bind(&LLFloaterModelPreview::onDescriptionKeystroke, this, _1), NULL); @@ -200,6 +212,7 @@ bool LLFloaterModelPreview::postBuild() getChild("show_skin_weight")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onShowSkinWeightChecked, this, _1)); getChild("show_joint_overrides")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1)); getChild("show_joint_positions")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1)); + getChild("show_origin")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1)); // Mesh uploader pivot childDisable("upload_skin"); childDisable("upload_joints"); @@ -495,6 +508,7 @@ void LLFloaterModelPreview::onClickCalculateBtn() clearLogTab(); addStringToLog("Calculating model data.", false); mModelPreview->rebuildUploadData(); + mModelPreview->updateStatusMessages(); // Mesh uploader pivot: whether a pivot is allowed follows the rebuilt data bool upload_skinweights = childGetValue("upload_skin").asBoolean(); bool upload_joint_positions = childGetValue("upload_joints").asBoolean(); @@ -510,7 +524,8 @@ void LLFloaterModelPreview::onClickCalculateBtn() childGetValue("upload_textures").asBoolean(), upload_skinweights, upload_joint_positions, lock_scale_if_joint_position, mUploadModelUrl, mDestinationFolderId, false, - getWholeModelFeeObserverHandle()); + getWholeModelFeeObserverHandle(), + LLHandle(), mModelPreview->getUploadPositionOffsets()); // Mesh uploader pivot toggleCalculateButton(false); mUploadBtn->setEnabled(false); @@ -675,6 +690,120 @@ void LLFloaterModelPreview::onImportScaleCommit(LLUICtrl*,void* userdata) fp->mModelPreview->refresh(); } + +// Mesh uploader pivot +static const char* const PIVOT_AXES[3] = { "pivot_x", "pivot_y", "pivot_z" }; + +LLVector3 LLFloaterModelPreview::getPivotHalfExtents() const +{ + if (!mModelPreview) + { + return LLVector3::zero; + } + if (mModelPreview->isSingleModel()) + { + // The pivot moves along the mesh's own axes, so a rotated instance measures along them. + // The transform carries the import scale it was built with; a scale changed since is applied here. + const LLMatrix4& mat = mModelPreview->mUploadData[0].mTransform; + const F32 built = mModelPreview->mPivotScale; + const F32 rescale = built > 0.f ? (F32)childGetValue("import_scale").asReal() / built : 1.f; + LLVector3 half; + for (S32 i = 0; i < 3; ++i) + { + half.mV[i] = LLVector3(mat.mMatrix[i][0], mat.mMatrix[i][1], mat.mMatrix[i][2]).length() * 0.5f * rescale; + } + return half; + } + return mModelPreview->mPreviewScale * (F32)childGetValue("import_scale").asReal(); +} + +void LLFloaterModelPreview::onPivotCommit() +{ + if (!mModelPreview) + { + return; + } + + const bool metres = childGetValue("pivot_units").asString() == "metres"; + const LLVector3 half = getPivotHalfExtents(); + LLVector3 pivot; + for (S32 i = 0; i < 3; ++i) + { + const F32 shown = (F32)childGetValue(PIVOT_AXES[i]).asReal(); + if (half.mV[i] <= 0.f) + { + pivot.mV[i] = 0.f; // a flat axis has nowhere to put it + } + else + { + pivot.mV[i] = metres ? shown / half.mV[i] : shown / 100.f; + } + pivot.mV[i] = llclamp(pivot.mV[i], -1.f, 1.f); + } + + mModelPreview->setCustomPivot(pivot); + mModelPreview->updateStatusMessages(); + toggleCalculateButton(true); + mModelPreview->refresh(); +} + +void LLFloaterModelPreview::onPivotUnitsCommit() +{ + updatePivotControls(); +} + +void LLFloaterModelPreview::onPivotModeCommit() +{ + if (!mModelPreview) + { + return; + } + const std::string mode = childGetValue("pivot_mode").asString(); + mModelPreview->setPivotMode(mode == "custom" ? LLModelPreview::PIVOT_CUSTOM + : mode == "centre" ? LLModelPreview::PIVOT_CENTRE + : LLModelPreview::PIVOT_FILE); + updatePivotControls(); + mModelPreview->updateStatusMessages(); + toggleCalculateButton(true); + mModelPreview->refresh(); +} + +void LLFloaterModelPreview::updatePivotControls() +{ + if (!mModelPreview) + { + return; + } + + // Custom is for one model: several each take their own origin from the file, or the centre. + const bool single = mModelPreview->isSingleModel(); + if (LLRadioGroup* modes = getChild("pivot_mode")) + { + if (!single && mModelPreview->getPivotMode() == LLModelPreview::PIVOT_CUSTOM) + { + mModelPreview->setPivotMode(LLModelPreview::PIVOT_FILE); + } + modes->setIndexEnabled(2, single); + const LLModelPreview::EPivotMode mode = mModelPreview->getPivotMode(); + modes->setValue(mode == LLModelPreview::PIVOT_CUSTOM ? "custom" : mode == LLModelPreview::PIVOT_CENTRE ? "centre" : "file"); + } + + const bool metres = childGetValue("pivot_units").asString() == "metres"; + const LLVector3 half = getPivotHalfExtents(); + const LLVector3 pivot = single ? mModelPreview->getWantedPivot(mModelPreview->mUploadData[0].mModel) : LLVector3::zero; + for (S32 i = 0; i < 3; ++i) + { + LLSpinCtrl* spinner = getChild(PIVOT_AXES[i]); + const F32 range = metres ? half.mV[i] : 100.f; + spinner->setMinValue(-range); + spinner->setMaxValue(range); + spinner->setIncrement(metres ? 0.01f : 50.f); + spinner->setPrecision(metres ? 3 : 1); + spinner->setValue(pivot.mV[i] * range); + } +} +// + //static void LLFloaterModelPreview::onPelvisOffsetCommit( LLUICtrl*, void* userdata ) { @@ -1722,6 +1851,7 @@ void LLFloaterModelPreview::onUpload(void* user_data) mp->mUploadBtn->setEnabled(false); mp->mModelPreview->rebuildUploadData(); + mp->mModelPreview->updateStatusMessages(); // Mesh uploader pivot: whether a pivot is allowed follows the rebuilt data bool upload_skinweights = mp->childGetValue("upload_skin").asBoolean(); bool upload_joint_positions = mp->childGetValue("upload_joints").asBoolean(); @@ -1739,7 +1869,8 @@ void LLFloaterModelPreview::onUpload(void* user_data) mp->childGetValue("upload_textures").asBoolean(), upload_skinweights, upload_joint_positions, lock_scale_if_joint_position, mp->mUploadModelUrl, mp->mDestinationFolderId, - true, LLHandle(), mp->getWholeModelUploadObserverHandle()); + true, LLHandle(), mp->getWholeModelUploadObserverHandle(), + mp->mModelPreview->getUploadPositionOffsets()); // Mesh uploader pivot } @@ -1873,6 +2004,15 @@ void LLFloaterModelPreview::resetDisplayOptions() void LLFloaterModelPreview::resetUploadOptions() { childSetValue("import_scale", 1); + // Mesh uploader pivot + childSetValue("pivot_units", "percent"); + if (mModelPreview) + { + mModelPreview->setPivotMode(LLModelPreview::PIVOT_FILE); + mModelPreview->setCustomPivot(LLVector3::zero); + } + updatePivotControls(); + // childSetValue("pelvis_offset", 0); childSetValue("physics_explode", 0); childSetValue("physics_file", ""); diff --git a/indra/newview/llfloatermodelpreview.h b/indra/newview/llfloatermodelpreview.h index 39df7448f0..347d215a9e 100644 --- a/indra/newview/llfloatermodelpreview.h +++ b/indra/newview/llfloatermodelpreview.h @@ -144,6 +144,13 @@ class LLFloaterModelPreview final : public LLFloaterModelUploadBase void onDescriptionKeystroke(LLUICtrl*); static void onImportScaleCommit(LLUICtrl*, void*); + // Mesh uploader pivot: shown in percent of the half-extent or in metres from the centre + void onPivotCommit(); + void onPivotUnitsCommit(); + void onPivotModeCommit(); + void updatePivotControls(); + LLVector3 getPivotHalfExtents() const; + // static void onPelvisOffsetCommit(LLUICtrl*, void*); static void onPreviewLODCommit(LLUICtrl*,void*); diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index f156ab0cf2..c039947010 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -27,6 +27,9 @@ #include "lllocalmesh.h" #include // std::sort (stable rez-order listing of spawned copies) +#include +#include +#include #include // mSpawnedCopies: instance id -> copy (O(1) per-copy lookup/erase) #include // dedup avatars when rebuilding overrides across copies @@ -53,6 +56,7 @@ #include "lllocalgltfmaterials.h" // import a glTF mesh's materials as mesh-owned local materials #include "llprimitive.h" // LL_PCODE_VOLUME #include "object_flags.h" // FLAGS_OBJECT_* for owner permissions +#include "llmodelpreview.h" // LLModelPreview::getFileOriginPivot, the uploader's origin rule #include "llnotificationsutil.h" // warn when a loaded mesh has no UVs #include "llscrolllistctrl.h" #include "llselectmgr.h" // deselect before re-parenting onto the avatar @@ -516,6 +520,141 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) importGLTFMaterials(gltf_mat_by_name, new_owned_materials); } + // A model named NAME_LOD0-2 or NAME_PHYS beside a NAME is one of NAME's + // lower LODs or its physics shape, as the uploader reads the file, not a + // part of its own. A submodel's letter follows the suffix: NAME_LOD2b. + static const std::array lod_suffixes{ "_LOD0", "_LOD1", "_LOD2", "_PHYS" }; + auto core_label = [](const LLModel* mdl) -> std::string_view + { + std::string_view label(mdl->mLabel); + return mdl->mSubmodelID > 0 && !label.empty() ? label.substr(0, label.size() - 1) : label; + }; + auto base_of = [&](std::string_view label) -> std::string_view + { + for (std::string_view suffix : lod_suffixes) + { + if (label.size() > suffix.size() && label.substr(label.size() - suffix.size()) == suffix) + { + return label.substr(0, label.size() - suffix.size()); + } + } + return {}; + }; + std::set> bases; + for (auto iter = scene.begin(); iter != scene.end(); ++iter) + { + for (const LLModelInstance& instance : iter->second) + { + const LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); + if (mdl && !mdl->mSubmodelID && base_of(mdl->mLabel).empty()) + { + bases.emplace(mdl->mLabel); + } + } + } + // Each part's own LOD and physics models, by the part's label (NAME, or NAMEb for a submodel): + // slots 0-2 are LOD0-2, slot 3 the physics shape. + std::map, std::less<>> lods_of; + // How many models (root and submodels) each part has at High, and at each of its own LOD slots: + // a LOD split differently from High would leave faces out, so it isn't served. + std::map> high_count; + std::map, std::less<>> lod_count; + for (auto iter = scene.begin(); iter != scene.end(); ++iter) + { + for (const LLModelInstance& instance : iter->second) + { + LLModel* mdl = instance.mModel.notNull() ? instance.mModel.get() : instance.mLOD[LLModel::LOD_HIGH].get(); + if (!mdl || mdl->getNumVolumeFaces() <= 0) + { + continue; + } + const std::string_view core = core_label(mdl); + const std::string_view base = base_of(core); + if (base.empty() || bases.find(base) == bases.end()) + { + if (base.empty()) + { + ++high_count[std::string(core)]; + } + continue; + } + const std::string_view suffix = core.substr(base.size()); + const size_t slot = suffix == "_PHYS" ? 3 : (size_t)(suffix.back() - '0'); + std::string key(base); + if (mdl->mSubmodelID > 0) + { + key += mdl->mLabel.back(); + } + lods_of[key][slot] = mdl; // value-initialised to nulls on first use + ++lod_count[std::string(base)][slot]; + } + } + // A static part stands on the origin its file gives, as the uploader's From file pivot places it: + // its geometry moves by the offset that brings that origin to the prim's, and the part moves back. + auto shift_face = [](LLVolumeFace& face, const LLVector4a& offset) + { + for (S32 v = 0; v < face.mNumVertices; ++v) + { + face.mPositions[v].add(offset); + } + if (face.mExtents) + { + face.mExtents[0].add(offset); + face.mExtents[1].add(offset); + } + if (face.mCenter) + { + face.mCenter->add(offset); + } + }; + + // A LOD or physics model's faces as a volume the repository can serve, built as the part's own is. + auto make_volume = [&](LLModel* mdl, const LLVector4a& offset) -> LLPointer + { + std::vector lod_faces; + lod_faces.reserve(mdl->getNumVolumeFaces()); + const LLModel::JointWeightCache lod_weights(*mdl); + for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) + { + LLVolumeFace face = mdl->getVolumeFace(fi); + if (unit_rigged) + { + populateFaceWeights(face, *mdl, lod_weights); + } + shift_face(face, offset); + lod_faces.push_back(face); + } + LLVolumeParams lod_params; + lod_params.setType(LL_PCODE_PROFILE_SQUARE, LL_PCODE_PATH_LINE); + LLPointer volume = new LLVolume(lod_params, 1.f); + volume->copyFacesFrom(lod_faces); + volume->cacheOptimize(true); + volume->setMeshAssetLoaded(true); + return volume; + }; + // A physics model as the triangle list the physics display draws, as a downloaded physics mesh is. + auto make_physics = [](const LLModel* mdl, const LLVector3& offset) -> std::shared_ptr + { + auto decomp = std::make_shared(); + std::vector& pos = decomp->mPhysicsShapeMesh.mPositions; + std::vector& norm = decomp->mPhysicsShapeMesh.mNormals; + for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) + { + const LLVolumeFace& face = mdl->getVolumeFace(fi); + for (S32 i = 0; i < face.mNumIndices; ++i) + { + const U16 idx = face.mIndices[i]; + if (idx < face.mNumVertices) + { + pos.emplace_back(LLVector3(face.mPositions[idx].getF32ptr()) + offset); + // A Collada model without normals has none; the physics display draws positions alone. + norm.emplace_back(face.mNormals ? LLVector3(face.mNormals[idx].getF32ptr()) : LLVector3::z_axis); + } + } + } + return decomp->mPhysicsShapeMesh.mPositions.empty() ? nullptr : decomp; + }; + for (auto iter = scene.begin(); iter != scene.end(); ++iter) { for (LLModelInstance& instance : iter->second) @@ -525,6 +664,15 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) { continue; } + const std::string_view base = base_of(core_label(mdl)); + if (!base.empty() && bases.find(base) != bases.end()) + { + continue; + } + // Rigged parts are placed by their skeleton, so only a static part takes its file's origin. + const LLVector3 pivot_offset = unit_rigged ? LLVector3::zero : LLModelPreview::getFileOriginPivot(mdl) * -0.5f; + LLVector4a pivot_offset4; + pivot_offset4.load3(pivot_offset.mV); std::vector faces; faces.reserve(mdl->getNumVolumeFaces()); @@ -534,6 +682,7 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) for (S32 fi = 0; fi < mdl->getNumVolumeFaces(); ++fi) { LLVolumeFace face = mdl->getVolumeFace(fi); // deep copy + shift_face(face, pivot_offset4); // Keep the loader's model-local geometry for both rigged and static // units; the instance transform is applied as the object transform // below (mirroring the upload), so geometry/normals/tangents stay in @@ -640,7 +789,7 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) decomposeInstanceMatrix(instance.mTransform, pos, rot, scl); part.mScale = scl; part.mRotation = rot; - part.mOffset = pos; // scene-space; spawn uses per-part differences + part.mOffset = (-pivot_offset) * instance.mTransform; // scene-space at the pivot; spawn uses per-part differences } LLVolumeParams vparams; @@ -656,6 +805,32 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) } part.mVolume->setMeshAssetLoaded(true); + auto own = lods_of.find(mdl->mLabel); + if (own != lods_of.end()) + { + const std::string_view own_base = core_label(mdl); + const auto high_it = high_count.find(own_base); + const auto lod_it = lod_count.find(own_base); + for (S32 lod = LLModel::LOD_IMPOSTOR; lod < LLModel::LOD_HIGH; ++lod) + { + LLModel* lod_mdl = own->second[lod]; + // Served only where it can stand in for High: split the same way, with its + // materials in the same order (faces take High's materials by index), and + // skinned where High is, or it would draw unskinned at that distance. + if (lod_mdl + && high_it != high_count.end() && lod_it != lod_count.end() && lod_it->second[lod] == high_it->second + && lod_mdl->mMaterialList == mdl->mMaterialList + && (!unit_rigged || !lod_mdl->mSkinWeights.empty())) + { + part.mLODVolumes[lod] = make_volume(lod_mdl, pivot_offset4); + } + } + if (LLModel* physics_mdl = own->second[3]) + { + part.mPhysics = make_physics(physics_mdl, pivot_offset); + } + } + if (!mdl->mSkinInfo.mJointNames.empty()) { // Owned copy bound to this part's world id. By default include_joints @@ -1115,10 +1290,24 @@ bool LLLocalMeshMgr::isLocal(const LLUUID& world_id) const return findPart(world_id) != nullptr; } -LLVolume* LLLocalMeshMgr::getVolumeForWorldID(const LLUUID& world_id) const +LLVolume* LLLocalMeshMgr::getVolumeForWorldID(const LLUUID& world_id, S32 lod) const +{ + const LLLocalMeshPart* part = findPart(world_id); + if (!part) + { + return nullptr; + } + if (lod >= LLModel::LOD_IMPOSTOR && lod < LLModel::LOD_HIGH && part->mLODVolumes[lod].notNull()) + { + return part->mLODVolumes[lod].get(); + } + return part->mVolume.get(); +} + +LLModel::Decomposition* LLLocalMeshMgr::getDecompositionForWorldID(const LLUUID& world_id) const { const LLLocalMeshPart* part = findPart(world_id); - return part ? part->mVolume.get() : nullptr; + return part ? part->mPhysics.get() : nullptr; } const LLMeshSkinInfo* LLLocalMeshMgr::getSkinInfoForWorldID(const LLUUID& world_id) const diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 09adb58999..a4828d493a 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,8 @@ struct LLLocalMeshPart std::string mName; // sub-mesh (model/instance) name, for build-floater display LLUUID mWorldID; // unique mesh id objects/repository reference LLPointer mVolume; // normalized geometry (<= 8 faces) + LLPointer mLODVolumes[LLModel::LOD_HIGH]; // the file's NAME_LOD0-2, null where it has none + std::shared_ptr mPhysics; // the file's NAME_PHYS as a physics mesh, null where it has none LLPointer mSkinInfo; // null if not rigged LLVector3 mScale; // object scale (decomposed instance scale; rigged: authored size) LLQuaternion mRotation; // object rotation (decomposed instance rotation; rigged: identity) @@ -236,7 +239,9 @@ class LLLocalMeshMgr : public LLSingleton // 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; + // The part's geometry for a LOD: the file's own model for it, else the High one. + LLVolume* getVolumeForWorldID(const LLUUID& world_id, S32 lod = LLModel::LOD_HIGH) const; + LLModel::Decomposition* getDecompositionForWorldID(const LLUUID& world_id) const; const LLMeshSkinInfo* getSkinInfoForWorldID(const LLUUID& world_id) const; // Despawn in-world preview copies but KEEP the loaded unit so it can be re-spawned diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index e2620c6fc5..5179702cf5 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -2699,7 +2699,8 @@ LLMeshUploadThread::LLMeshUploadThread(LLMeshUploadThread::instance_list_t& data bool upload_skin, bool upload_joints, bool lock_scale_if_joint_position, const std::string & upload_url, LLUUID destination_folder_id, bool do_upload, LLHandle fee_observer, - LLHandle upload_observer) + LLHandle upload_observer, + const position_offsets_t& position_offsets) // Mesh uploader pivot : LLThread("mesh upload"), LLCore::HttpHandler(), mDiscarded(false), @@ -2715,6 +2716,7 @@ LLMeshUploadThread::LLMeshUploadThread(LLMeshUploadThread::instance_list_t& data mUploadSkin = upload_skin; mUploadJoints = upload_joints; mLockScaleIfJointPosition = lock_scale_if_joint_position; + mPositionOffsets = position_offsets; // Mesh uploader pivot mMutex = new LLMutex(); mPendingUploads = 0; mFinished = false; @@ -2825,6 +2827,14 @@ LLSD llsd_from_file(std::string filename) return result; } +// Mesh uploader pivot +LLVector3 LLMeshUploadThread::getPositionOffset(const LLModel* model) const +{ + auto it = mPositionOffsets.find(model); + return it != mPositionOffsets.end() ? it->second : LLVector3::zero; +} +// + void LLMeshUploadThread::packModelIntance( LLModel* model, LLMeshUploadThread::instance_list_t& instance_list, @@ -2879,7 +2889,8 @@ void LLMeshUploadThread::packModelIntance( mLockScaleIfJointPosition, LLModel::WRITE_BINARY, false, - data.mBaseModel->mSubmodelID); + data.mBaseModel->mSubmodelID, + getPositionOffset(data.mBaseModel)); // Mesh uploader pivot data.mAssetData = ostr.str(); std::string str = ostr.str(); @@ -2907,6 +2918,9 @@ void LLMeshUploadThread::packModelIntance( LLQuaternion rot; LLMatrix4 transformation = instance.mTransform; decomposeMeshMatrix(transformation, pos, rot, scale); + // Mesh uploader pivot: the prim stands on its pivot, so the mesh stays where it was placed + pos = (-getPositionOffset(data.mBaseModel)) * transformation + mOrigin; + // instance_entry["position"] = ll_sd_from_vector3(pos); instance_entry["rotation"] = ll_sd_from_quaternion(rot); instance_entry["scale"] = ll_sd_from_vector3(scale); @@ -4620,7 +4634,7 @@ S32 LLMeshRepository::loadMesh(LLVOVolume* vobj, const LLVolumeParams& mesh_para } // Local mesh: serve the decoded geometry directly from the registry instead - // of issuing an asset fetch. The same geometry is served for every LOD. + // of issuing an asset fetch. Each LOD gets the file's own model for it, or the High one. // Local mesh LODs // Local mesh: decide locality by registry membership, not by whether a decoded // volume happens to exist right now. A local-only world id must never fall through // to the simulator-backed fetch below -- not even during the brief window of a @@ -4628,7 +4642,7 @@ S32 LLMeshRepository::loadMesh(LLVOVolume* vobj, const LLVolumeParams& mesh_para if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_params.getSculptID())) { - LLVolume* local_volume = LLLocalMeshMgr::getInstance()->getVolumeForWorldID(mesh_params.getSculptID()); + LLVolume* local_volume = LLLocalMeshMgr::getInstance()->getVolumeForWorldID(mesh_params.getSculptID(), new_lod); // Local mesh LODs: the file's own model for this LOD if (local_volume) { LLVolume* sys_volume = LLPrimitive::getVolumeManager()->refVolume(mesh_params, new_lod); @@ -5421,7 +5435,7 @@ LLModel::Decomposition* LLMeshRepository::getDecomposition(const LLUUID& mesh_id // forever, retrying). if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) { - return ret; + return LLLocalMeshMgr::getInstance()->getDecompositionForWorldID(mesh_id); // Local mesh LODs: the file's physics model } if (mesh_id.notNull()) @@ -5475,7 +5489,7 @@ bool LLMeshRepository::hasPhysicsShape(const LLUUID& mesh_id) // decomposition fetch below). if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) { - return false; + return LLLocalMeshMgr::getInstance()->getDecompositionForWorldID(mesh_id) != nullptr; // Local mesh LODs: the file's physics model } if (const LLMeshHeader* header = mHeaderCache.findValid(mesh_id)) @@ -5556,11 +5570,13 @@ void LLMeshRepository::uploadModel(std::vector& data, const std LLVector3& scale, bool upload_textures, bool upload_skin, bool upload_joints, bool lock_scale_if_joint_position, std::string upload_url, const LLUUID& destination_folder_id, bool do_upload, - LLHandle fee_observer, LLHandle upload_observer) + LLHandle fee_observer, LLHandle upload_observer, + const LLMeshUploadThread::position_offsets_t& position_offsets) // Mesh uploader pivot { LLMeshUploadThread* thread = new LLMeshUploadThread(data, lod_sources, scale, upload_textures, upload_skin, upload_joints, lock_scale_if_joint_position, - upload_url, destination_folder_id, do_upload, fee_observer, upload_observer); + upload_url, destination_folder_id, do_upload, fee_observer, upload_observer, + position_offsets); // Mesh uploader pivot mUploadWaitList.push_back(thread); } diff --git a/indra/newview/llmeshrepository.h b/indra/newview/llmeshrepository.h index 5060e55d05..5e5ebc7993 100644 --- a/indra/newview/llmeshrepository.h +++ b/indra/newview/llmeshrepository.h @@ -801,6 +801,11 @@ class LLMeshUploadThread : public LLThread, public LLCore::HttpHandler bool mUploadSkin; bool mUploadJoints; bool mLockScaleIfJointPosition; + // Mesh uploader pivot: added to each base model's stored domains + typedef boost::unordered_map position_offsets_t; + position_offsets_t mPositionOffsets; + LLVector3 getPositionOffset(const LLModel* model) const; + // volatile bool mDiscarded; LLHost mHost; @@ -815,7 +820,8 @@ class LLMeshUploadThread : public LLThread, public LLCore::HttpHandler const LLUUID destination_folder_id = LLUUID::null, bool do_upload = true, LLHandle fee_observer = (LLHandle()), - LLHandle upload_observer = (LLHandle())); + LLHandle upload_observer = (LLHandle()), + const position_offsets_t& position_offsets = position_offsets_t()); // Mesh uploader pivot ~LLMeshUploadThread(); bool finished() const { return mFinished; } @@ -1008,7 +1014,8 @@ class LLMeshRepository const LLUUID& destination_folder_id = LLUUID::null, bool do_upload = true, LLHandle fee_observer= (LLHandle()), - LLHandle upload_observer = (LLHandle())); + LLHandle upload_observer = (LLHandle()), + const LLMeshUploadThread::position_offsets_t& position_offsets = LLMeshUploadThread::position_offsets_t()); // Mesh uploader pivot S32 getMeshSize(const LLUUID& mesh_id, S32 lod) const; diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 374821d3e4..f5e3db2816 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -279,6 +279,13 @@ void LLModelPreview::updateDimentionsAndOffsets() mDetailsSignal((F32)(mPreviewScale[0] * scale), (F32)(mPreviewScale[1] * scale), (F32)(mPreviewScale[2] * scale)); + // Mesh uploader pivot: metres follow the model's size + if (LLFloaterModelPreview::sInstance && LLFloaterModelPreview::sInstance->mModelPreview == this) + { + LLFloaterModelPreview::sInstance->updatePivotControls(); + } + // + updateStatusMessages(); } @@ -287,6 +294,26 @@ void LLModelPreview::rebuildUploadData() assert_main_thread(); mDefaultPhysicsShapeP = NULL; + // Mesh uploader LODs by name: physics set to use a LOD is that LOD as it stands now, however + // it changed since (a new high file, the file's own LODs, generation, a LOD file of its own) + if (mPhysicsSearchLOD >= LLModel::LOD_IMPOSTOR && mPhysicsSearchLOD <= LLModel::LOD_HIGH + && mLODFile[LLModel::LOD_PHYSICS].empty() && !mLODFromHighFile[LLModel::LOD_PHYSICS] + && mModel[LLModel::LOD_PHYSICS] != mModel[mPhysicsSearchLOD]) + { + mModel[LLModel::LOD_PHYSICS] = mModel[mPhysicsSearchLOD]; + mScene[LLModel::LOD_PHYSICS] = mScene[mPhysicsSearchLOD]; + mVertexBuffer[LLModel::LOD_PHYSICS].clear(); + } + // + // Mesh uploader LODs by name: physics from the high file names its default shape as a physics file does + if (mLODFromHighFile[LLModel::LOD_PHYSICS]) + { + LLMatrix4 ignored_transform; + LLModel* default_shape = nullptr; + FindModel(mScene[LLModel::LOD_PHYSICS], DEFAULT_PHYSICS_MESH_NAME + getLodSuffix(LLModel::LOD_PHYSICS), default_shape, ignored_transform); + mDefaultPhysicsShapeP = default_shape; + } + // mUploadData.clear(); mTextureSet.clear(); @@ -297,6 +324,7 @@ void LLModelPreview::rebuildUploadData() LLSpinCtrl* scale_spinner = mFMP->getChild("import_scale"); F32 scale = (F32)scale_spinner->getValue().asReal(); + mPivotScale = scale; // Mesh uploader pivot: the scale the transforms below carry LLMatrix4 scale_mat; scale_mat.initScale(LLVector3(scale, scale, scale)); @@ -885,6 +913,7 @@ void LLModelPreview::setPhysicsFromLOD(S32 lod) if (lod >= 0 && lod <= 3) { mPhysicsSearchLOD = lod; + mLODFromHighFile[LLModel::LOD_PHYSICS] = false; // Mesh uploader LODs by name mModel[LLModel::LOD_PHYSICS] = mModel[lod]; mScene[LLModel::LOD_PHYSICS] = mScene[lod]; mLODFile[LLModel::LOD_PHYSICS].clear(); @@ -1007,6 +1036,7 @@ void LLModelPreview::loadModelCallback(S32 loaded_lod) if (loaded_lod == -1) { //populate all LoDs from model loader scene mSimplifiers.clear(); + std::fill(std::begin(mLODFromHighFile), std::end(mLODFromHighFile), false); // Mesh uploader LODs by name mBaseModel.clear(); mBaseScene.clear(); @@ -1113,6 +1143,16 @@ void LLModelPreview::loadModelCallback(S32 loaded_lod) { //only replace given LoD mModel[loaded_lod] = mModelLoader->mModelList; mScene[loaded_lod] = mModelLoader->mScene; + // Mesh uploader LODs by name: a file of its own replaces what the high file gave + if (loaded_lod != LLModel::LOD_HIGH) + { + mLODFromHighFile[loaded_lod] = false; + } + else + { + splitSuffixedModels(mModelLoader->mFilename); + } + // // Duplicate the model if it is an internal bounding box model if (loaded_lod == LLModel::LOD_PHYSICS && @@ -1839,6 +1879,341 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d << ", failures: " << meshes_fail_count << LL_ENDL; } +// Mesh uploader pivot +void LLModelPreview::setPivotMode(EPivotMode mode) +{ + if (mode == PIVOT_CUSTOM && mPivotMode != PIVOT_CUSTOM && isSingleModel()) + { + mCustomPivot = getWantedPivot(mUploadData[0].mModel); + } + mPivotMode = mode; +} + +// static +LLVector3 LLModelPreview::getFileOriginPivot(const LLModel* model) +{ + if (!model) + { + return LLVector3::zero; + } + // The loader centred the model on its box; the file's origin is where it moved the centre from. + LLVector3 size, centre; + model->getNormalizedScaleTranslation(size, centre); + LLVector3 pivot; + for (S32 i = 0; i < 3; ++i) + { + pivot.mV[i] = size.mV[i] > 0.f ? -2.f * centre.mV[i] / size.mV[i] : 0.f; + // An origin outside the box is a scene's, from a file that placed its vertices in the scene: centre it. + if (fabsf(pivot.mV[i]) > 1.001f) + { + return LLVector3::zero; + } + pivot.mV[i] = llclamp(pivot.mV[i], -1.f, 1.f); + } + return pivot; +} + +LLVector3 LLModelPreview::getWantedPivot(const LLModel* model) const +{ + switch (mPivotMode) + { + case PIVOT_FILE: + return getFileOriginPivot(model); + case PIVOT_CUSTOM: + return isSingleModel() ? mCustomPivot : LLVector3::zero; + default: + return LLVector3::zero; + } +} + +// Mesh uploader LODs by name +void LLModelPreview::splitSuffixedModels(const std::string& filename) +{ + bool was_from_file[LLModel::NUM_LODS]; + std::copy(std::begin(mLODFromHighFile), std::end(mLODFromHighFile), was_from_file); + for (bool& from_file : mLODFromHighFile) + { + from_file = false; + } + // A submodel is named after its root with a letter added, after any suffix: NAME_LOD2b. + auto core_of = [](const LLModel* model) -> std::string + { + return model->mSubmodelID > 0 && !model->mLabel.empty() ? model->mLabel.substr(0, model->mLabel.size() - 1) : model->mLabel; + }; + auto letter_of = [](const LLModel* model) -> std::string + { + return model->mSubmodelID > 0 && !model->mLabel.empty() ? model->mLabel.substr(model->mLabel.size() - 1) : std::string(); + }; + auto suffix_lod = [](const std::string& label) -> S32 + { + for (S32 lod : { (S32)LLModel::LOD_IMPOSTOR, (S32)LLModel::LOD_LOW, (S32)LLModel::LOD_MEDIUM, (S32)LLModel::LOD_PHYSICS }) + { + const std::string suffix = getLodSuffix(lod); + if (label.size() > suffix.size() && label.compare(label.size() - suffix.size(), suffix.size(), suffix) == 0) + { + return lod; + } + } + return -1; + }; + + const bool legacy = gSavedSettings.getBOOL("ImporterLegacyMatching"); + // Only a suffixed model whose base the file also has is a LOD; any other is a model of its own. + std::set bases; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + if (model && !model->mSubmodelID && suffix_lod(model->mLabel) < 0) + { + bases.insert(model->mLabel); + } + } + auto lod_of = [&](const LLModel* model) -> S32 + { + if (!model) + { + return -1; + } + const std::string core = core_of(model); + if (legacy) + { + return -1; + } + // The shape for parts with no _PHYS of their own goes to physics whether or not a part shares its name. + if (core == DEFAULT_PHYSICS_MESH_NAME + getLodSuffix(LLModel::LOD_PHYSICS)) + { + return LLModel::LOD_PHYSICS; + } + const S32 lod = suffix_lod(core); + return lod >= 0 && bases.count(stripSuffix(core)) ? lod : -1; + }; + + // LODs an earlier file filled and this one does not are generated again. + LLFloaterModelPreview* fmp = LLFloaterModelPreview::sInstance; + auto release = [&](S32 lod) + { + mModel[lod].clear(); + mScene[lod].clear(); + mLODFile[lod].clear(); + mVertexBuffer[lod].clear(); + if (fmp && lod != LLModel::LOD_PHYSICS) + { + // Only a LOD still set to come from the file goes back to generation; another source stays. + LLComboBox* lod_combo = fmp->findChild("lod_source_" + lod_name[lod]); + if (!lod_combo || lod_combo->getCurrentIndex() == LOD_FROM_FILE) + { + fmp->mLODMode[lod] = MESH_OPTIMIZER_AUTO; + if (lod_combo) + { + lod_combo->setCurrentByIndex(MESH_OPTIMIZER_AUTO); + } + } + fmp->childSetValue("lod_file_" + lod_name[lod], ""); + } + else if (fmp) + { + // Physics the high file no longer supplies is no longer from a file: back to none, as a reset leaves it. + fmp->childSetValue("physics_file", ""); + fmp->childSetValue("physics_lod_combo", "none"); + } + }; + + // A suffixed model with no base is named as the loader always named it, without its suffix. Models + // are found first, so every instance of one shared model takes the new name, not only the first. + std::set leftovers; + for (auto& scene_pair : mScene[LLModel::LOD_HIGH]) + { + for (LLModelInstance& instance : scene_pair.second) + { + LLModel* model = instance.mModel; + if (model && suffix_lod(core_of(model)) >= 0 && lod_of(model) < 0) + { + leftovers.insert(model); + } + } + } + for (LLModel* model : leftovers) + { + model->mLabel = stripSuffix(core_of(model)) + letter_of(model); + } + for (auto& scene_pair : mScene[LLModel::LOD_HIGH]) + { + for (LLModelInstance& instance : scene_pair.second) + { + if (leftovers.count(instance.mModel)) + { + instance.mLabel = instance.mModel->mLabel; // a submodel keeps its letter, so it isn't taken for its root + } + } + } + + // Each model's LOD is settled once, before any is renamed. + std::map moved; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = lod_of(model); + if (lod >= 0) + { + moved[model.get()] = lod; + } + } + auto moved_to = [&](const LLModel* model) -> S32 + { + auto it = moved.find(model); + return it != moved.end() ? it->second : -1; + }; + + bool split[LLModel::NUM_LODS] = {}; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = moved_to(model); + if (lod >= 0 && !split[lod]) + { + split[lod] = true; + mModel[lod].clear(); + mScene[lod].clear(); + } + } + LLModelLoader::model_list kept; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = moved_to(model); + if (lod < 0) + { + kept.push_back(model); + } + else + { + // Named as a separate LOD file would name it, so the matching finds a submodel too. + model->mLabel = stripSuffix(core_of(model)) + letter_of(model) + getLodSuffix(lod); + mModel[lod].push_back(model); + } + } + for (S32 lod = 0; lod < LLModel::NUM_LODS; ++lod) + { + if (was_from_file[lod] && !split[lod]) + { + release(lod); + if (lod != LLModel::LOD_PHYSICS) + { + updateLodControls(lod); + } + } + } + if (kept.size() == mModel[LLModel::LOD_HIGH].size()) + { + return; + } + mModel[LLModel::LOD_HIGH] = kept; + + for (auto scene_it = mScene[LLModel::LOD_HIGH].begin(); scene_it != mScene[LLModel::LOD_HIGH].end();) + { + LLModelLoader::model_instance_list& list = scene_it->second; + for (auto it = list.begin(); it != list.end();) + { + const S32 lod = moved_to(it->mModel); + if (lod < 0) + { + ++it; + continue; + } + mScene[lod][scene_it->first].push_back(*it); + it = list.erase(it); + } + scene_it = list.empty() ? mScene[LLModel::LOD_HIGH].erase(scene_it) : std::next(scene_it); + } + + if (split[LLModel::LOD_PHYSICS]) + { + // As an explicit physics file does: the parts with no _PHYS are logged, and may use a default shape. + mPhysicsSearchLOD = LLModel::LOD_PHYSICS; + mWarnOfUnmatchedPhyicsMeshes = true; + } + + for (S32 lod = 0; lod < LLModel::NUM_LODS; ++lod) + { + if (!split[lod]) + { + continue; + } + // A LOD needs every model and submodel, each by its own name: a partial set is dropped and the LOD generated. + auto complete = [&]() + { + if (mModel[lod].size() != kept.size()) + { + return false; + } + std::set names; + for (const LLPointer& model : mModel[lod]) + { + names.insert(model->mLabel); + } + return std::all_of(kept.begin(), kept.end(), [&](const LLPointer& model) + { return names.count(model->mLabel + getLodSuffix(lod)) > 0; }); + }; + if (lod != LLModel::LOD_PHYSICS && !complete()) + { + release(lod); + updateLodControls(lod); + std::ostringstream out; + out << "Only some models have a " << getLodSuffix(lod) << " model in the file; generating that LOD instead"; + LLFloaterModelPreview::addStringToLog(out, true); + continue; + } + mLODFromHighFile[lod] = true; + mLODFile[lod] = filename; + mVertexBuffer[lod].clear(); + if (fmp) + { + fmp->setCtrlLoadFromFile(lod); + if (lod == LLModel::LOD_PHYSICS) + { + fmp->childSetValue("physics_file", filename); + } + else + { + fmp->mLODMode[lod] = LOD_FROM_FILE; + fmp->childSetValue("lod_file_" + lod_name[lod], filename); + updateLodControls(lod); + } + } + std::ostringstream out; + out << "Took " << mModel[lod].size() << " model(s) named " << getLodSuffix(lod) << " from the file for that slot"; + LLFloaterModelPreview::addStringToLog(out, false); + } +} +// + +bool LLModelPreview::uploadsRigged(const LLModelInstance& instance) const +{ + // The upload takes its skin from the High model, which may carry weights its base does not. + if (!mFMP || !mFMP->childGetValue("upload_skin").asBoolean()) + { + return false; + } + const LLModel* high = instance.mLOD[LLModel::LOD_HIGH]; + return (instance.mModel && !instance.mModel->mSkinWeights.empty()) || (high && !high->mSkinWeights.empty()); +} + +bool LLModelPreview::isSingleModel() const +{ + // A model with more than eight materials uploads as a root and its submodels, sharing one transform. + return std::count_if(mUploadData.begin(), mUploadData.end(), + [](const LLModelInstance& instance) { return instance.mModel && !instance.mModel->mSubmodelID; }) == 1; +} + + +LLMeshUploadThread::position_offsets_t LLModelPreview::getUploadPositionOffsets() const +{ + LLMeshUploadThread::position_offsets_t offsets; + for (const LLModelInstance& instance : mUploadData) + { + // Rigged mesh is placed by its skeleton and keeps the default position domain, whatever the pivot. + offsets[instance.mModel.get()] = uploadsRigged(instance) ? LLVector3::zero : getUploadPivot(instance.mModel) * -0.5f; + } + return offsets; +} +// + void LLModelPreview::updateStatusMessages() { // bit mask values for physics errors. used to prevent overwrite of single line status @@ -2182,6 +2557,77 @@ void LLModelPreview::updateStatusMessages() } } + // Mesh uploader pivot: the sim keeps analyzed hulls where the pivot puts them but fits + // any other physics shape to the box, so a pivot needs analyzed physics or none at all. + { + // Each part is judged on its own, so one rigged or unanalyzed part holds only itself at the centre. + bool any_rigged = false; + bool any_unanalyzed = false; + bool pivot_without_physics = false; // a pivoted part that collides with a default hull centred on itself + mPivotHeld.clear(); + for (const LLModelInstance& instance : mUploadData) + { + const LLModel* model = instance.mModel; + if (!model) + { + continue; + } + // Skin weights left out of the upload make a static mesh, which can take a pivot. + const bool rigged = uploadsRigged(instance); + // The hulls uploaded are the physics model's, or the base model's without one. + const LLModel* physics = instance.mLOD[LLModel::LOD_PHYSICS]; + bool instance_physics = false; + bool unanalyzed = false; + if (physics && physics->getNumVolumeFaces() > 0) + { + instance_physics = true; + unanalyzed = physics->mPhysics.mHull.empty(); + } + else if (!physics && !model->mPhysics.mHull.empty()) + { + instance_physics = true; + } + if (rigged || unanalyzed) + { + mPivotHeld.insert(model); + } + any_rigged |= rigged; + any_unanalyzed |= unanalyzed && !rigged; + pivot_without_physics |= !rigged && !instance_physics && !getWantedPivot(model).isExactlyZero(); + } + + // Some instance must be free of the hold: several instances of one held model are all held. + mUploadPivotAllowed = std::any_of(mUploadData.begin(), mUploadData.end(), [this](const LLModelInstance& instance) + { return instance.mModel && !mPivotHeld.count(instance.mModel); }); + // Every reason that applies is shown, since each concerns different parts. + std::string pivot_status; + auto add_status = [&](const char* name) + { + pivot_status += (pivot_status.empty() ? "" : " ") + mFMP->getString(name); + }; + if (any_rigged) + { + add_status("pivot_status_rigged"); + } + if (any_unanalyzed) + { + add_status("pivot_status_analyze"); + } + if (pivot_without_physics) + { + add_status("pivot_status_no_physics"); + } + + const bool custom = mFMP->childGetValue("pivot_mode").asString() == "custom"; + for (const char* axis : { "pivot_x", "pivot_y", "pivot_z" }) + { + mFMP->childSetEnabled(axis, mUploadPivotAllowed && custom); + } + mFMP->childSetEnabled("pivot_mode", mUploadPivotAllowed); + mFMP->childSetValue("pivot_status", pivot_status); + } + // + if (!mModelNoErrors || mHasDegenerate) { mFMP->childDisable("ok_btn"); @@ -2699,7 +3145,10 @@ void LLModelPreview::update() for (S32 lod = LLModel::LOD_HIGH; lod >= 0; --lod) { // adding all lods into query for generation - mLodsQuery.push_back(lod); + if (!mLODFromHighFile[lod]) // Mesh uploader LODs by name: the file gave this one + { + mLodsQuery.push_back(lod); + } } if (subscribe_for_generation) @@ -3641,6 +4090,82 @@ bool LLModelPreview::render() } } + // Mesh uploader pivot: the object's origin, drawn through the model + if (mViewOption["show_origin"] && !mViewOption["show_skin_weight"]) // rigged mesh is drawn on the avatar + { + LLGLSLShader* shader = LLGLSLShader::sCurBoundShaderPtr; + gUIProgram.bind(); // takes the vertex colours, which gDebugProgram replaces with one uniform + gGL.getTextureSlot(0)->unbind(); + LLGLDepthTest gls_depth(GL_FALSE); + LLGLDisable no_cull(GL_CULL_FACE); + gGL.setLineWidth(2.f); + + for (const LLModelInstance& instance : mUploadData) + { + const LLMatrix4& mat = instance.mTransform; + const LLVector3 origin = (getUploadPivot(instance.mModel) * 0.5f) * mat; + + LLVector3 axes[3]; + F32 reach = 0.f; + for (S32 i = 0; i < 3; ++i) + { + axes[i].set(mat.mMatrix[i][0], mat.mMatrix[i][1], mat.mMatrix[i][2]); + reach = llmax(reach, axes[i].normalize()); + } + reach *= 0.6f; + + // Arrows as the translate gizmo draws them: a shaft and a solid cone per axis. + static const LLColor4 colors[3] = { LLColor4::red, LLColor4::green, LLColor4::blue }; + const F32 head_length = reach * 0.2f; + const F32 head_radius = reach * 0.06f; + constexpr S32 HEAD_SEGMENTS = 12; + + gGL.begin(LLRender::LINES); + for (S32 i = 0; i < 3; ++i) + { + gGL.color4fv(colors[i].mV); + gGL.vertex3fv(origin.mV); + gGL.vertex3fv((origin + axes[i] * (reach - head_length)).mV); + } + gGL.end(); + + gGL.begin(LLRender::TRIANGLES); + for (S32 i = 0; i < 3; ++i) + { + const LLVector3 tip = origin + axes[i] * reach; + const LLVector3 base = origin + axes[i] * (reach - head_length); + const LLVector3 u = axes[(i + 1) % 3] * head_radius; + const LLVector3 v = axes[(i + 2) % 3] * head_radius; + for (S32 s = 0; s < HEAD_SEGMENTS; ++s) + { + const F32 a0 = F_TWO_PI * s / HEAD_SEGMENTS; + const F32 a1 = F_TWO_PI * (s + 1) / HEAD_SEGMENTS; + const LLVector3 r0 = base + u * cosf(a0) + v * sinf(a0); + const LLVector3 r1 = base + u * cosf(a1) + v * sinf(a1); + // Shade the side by its angle so the cone reads as solid. + const F32 shade = 0.65f + 0.35f * cosf(a0); + gGL.color4f(colors[i].mV[VRED] * shade, colors[i].mV[VGREEN] * shade, colors[i].mV[VBLUE] * shade, 1.f); + gGL.vertex3fv(tip.mV); + gGL.vertex3fv(r0.mV); + gGL.vertex3fv(r1.mV); + gGL.color4f(colors[i].mV[VRED] * 0.5f, colors[i].mV[VGREEN] * 0.5f, colors[i].mV[VBLUE] * 0.5f, 1.f); + gGL.vertex3fv(base.mV); + gGL.vertex3fv(r1.mV); + gGL.vertex3fv(r0.mV); + } + } + gGL.end(); + } + gGL.flush(); + gGL.setLineWidth(1.f); + + if (shader) + { + shader->bind(); + } + } + // + gObjectPreviewProgram.unbind(); gGL.popMatrix(); diff --git a/indra/newview/llmodelpreview.h b/indra/newview/llmodelpreview.h index b62030a349..12bd88c73f 100644 --- a/indra/newview/llmodelpreview.h +++ b/indra/newview/llmodelpreview.h @@ -172,6 +172,28 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH void setHasPivot(bool val) { mHasPivot = val; } void setModelPivot(const LLVector3& pivot) { mModelPivot = pivot; } + // Mesh uploader pivot: each model's origin within its bounding box, -1 to 1 per axis + enum EPivotMode { PIVOT_FILE, PIVOT_CENTRE, PIVOT_CUSTOM }; + // Custom starts from the pivot the last mode gave a single model. + void setPivotMode(EPivotMode mode); + EPivotMode getPivotMode() const { return mPivotMode; } + void setCustomPivot(const LLVector3& pivot) { mCustomPivot = pivot; } + // The origin the file gave the model, where its vertices' origin sits in its box. + static LLVector3 getFileOriginPivot(const LLModel* model); + // The pivot the mode asks for, and the one in effect: centred while the physics or rigging rules it out. + LLVector3 getWantedPivot(const LLModel* model) const; + LLVector3 getUploadPivot(const LLModel* model) const { return mPivotHeld.count(model) ? LLVector3::zero : getWantedPivot(model); } + // What each model's stored domains are shifted by so that its origin lands on its pivot. + LLMeshUploadThread::position_offsets_t getUploadPositionOffsets() const; + // One model to upload, counting a root and its submodels once: what Custom and metres are for. + bool isSingleModel() const; + // Whether this instance uploads skinned, from its base or its High model: such mesh takes no pivot. + bool uploadsRigged(const LLModelInstance& instance) const; + // + // Mesh uploader LODs by name: a file's NAME_LOD0-2 and NAME_PHYS models fill those slots of NAME + void splitSuffixedModels(const std::string& filename); + // + //Is a rig valid so that it can be used as a criteria for allowing for uploading of joint positions //Accessors for joint position upload friendly rigs const bool isRigValidForJointPositionUpload(void) const { return mRigValidJointUpload; } @@ -271,6 +293,14 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH bool mResetJoints; bool mModelNoErrors; bool mLookUpLodFiles; + // Mesh uploader pivot + EPivotMode mPivotMode{PIVOT_FILE}; + LLVector3 mCustomPivot; + bool mUploadPivotAllowed{true}; // some part may take a pivot + std::set mPivotHeld; // parts held at the centre: rigged, or with physics not yet analyzed + F32 mPivotScale{1.f}; // the import scale the upload data was last built with + bool mLODFromHighFile[LLModel::NUM_LODS]{}; // Mesh uploader LODs by name + // std::map mViewOption; diff --git a/indra/newview/skins/default/xui/en/floater_model_preview.xml b/indra/newview/skins/default/xui/en/floater_model_preview.xml index dee158eaa9..ac7527fdb2 100644 --- a/indra/newview/skins/default/xui/en/floater_model_preview.xml +++ b/indra/newview/skins/default/xui/en/floater_model_preview.xml @@ -35,6 +35,9 @@ LOD materials are not a subset of reference model. Some physical hulls exceed vertex limitations. The physics mesh is too dense or contains degenerate triangles. Use Analyze/Simplify to resolve. + A pivot is for unrigged mesh; rigged mesh is placed by its skeleton. + Analyze the physics to set a pivot: unanalyzed physics stays centred on the object. + No physics shape is defined, so the object collides with its default hull, centred on itself. Fine for a phantom piece or physics type None. All Analyzing... Simplifying... @@ -1365,6 +1368,119 @@ label_text.text_color="White" left="20" top_pad="20"/> + + + Pivot: + + + + + + + + + + + + + + + + height="340"/> @@ -1934,8 +2048,18 @@ Analysed: label_text.text_color="White" layout="topleft" name="show_joint_positions" - top_pad="21"> + top_pad="10"> + + + +