Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6202424
Add a pivot option to the mesh uploader
Martin-Pitt Oct 29, 2026
7221019
Show the uploader pivot in percent or metres
Martin-Pitt Oct 29, 2026
d02c107
Merge branch 'develop' into feature-ui/mesh-uploader-pivot
Martin-Pitt Oct 31, 2026
679e516
Fit the uploader's display options inside their panel
Martin-Pitt Nov 2, 2026
94e2d2d
Give the pivot units their values back
Martin-Pitt Nov 2, 2026
ba999ae
Draw the uploader origin as the translate gizmo's arrows
Martin-Pitt Nov 2, 2026
b4b6b16
Merge branch 'develop' into feature-ui/mesh-uploader-pivot
Martin-Pitt Nov 2, 2026
ded3ab2
Colour the uploader origin's arrows per axis
Martin-Pitt Nov 2, 2026
23f6854
Measure the uploader pivot along the mesh's own axes
Martin-Pitt Nov 2, 2026
e0db0c7
Take the uploader pivot from the file by default
Martin-Pitt Nov 3, 2026
274f11e
Lay the pivot modes out in a row
Martin-Pitt Nov 3, 2026
45300ab
Set the pivot spinners beside its modes
Martin-Pitt Nov 3, 2026
e19466f
Place uploaded meshes from the region's origin again
Martin-Pitt Nov 3, 2026
e25b56a
Measure the pivot's metres without scaling twice
Martin-Pitt Nov 3, 2026
0fded67
Merge branch 'develop' into feature-ui/mesh-uploader-pivot
Martin-Pitt Nov 3, 2026
94aea1d
Hide the uploader origin while skin weights are shown
Martin-Pitt Nov 3, 2026
d41e240
Settle the pivot against the data an upload sends
Martin-Pitt Nov 3, 2026
cd5ed5a
Guide setting object origins for the uploader's pivot
Martin-Pitt Nov 3, 2026
5c751f8
Show the uploader's Pivot row in the origins guide
Martin-Pitt Nov 3, 2026
23ec905
Count submodels with their root for the pivot, and warn per part
Martin-Pitt Nov 3, 2026
82b3dbe
Hold only the parts that cannot take a pivot
Martin-Pitt Nov 3, 2026
b83cdc5
Enable the pivot by instance and show every reason it is held
Martin-Pitt Nov 3, 2026
2c91233
Merge branch 'develop' into feature-ui/mesh-uploader-pivot
Martin-Pitt Nov 4, 2026
10abe5f
Merge branch 'develop' into feature-ui/mesh-uploader-pivot
Martin-Pitt Nov 4, 2026
20f8074
Keep rigged mesh on the default position domain
Martin-Pitt Nov 4, 2026
093903c
Merge remote-tracking branch 'origin/feature-ui/mesh-uploader-pivot' …
Martin-Pitt Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added doc/mesh/images/uploader-pivot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions doc/mesh/object-origins.md
Original file line number Diff line number Diff line change
@@ -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 |
18 changes: 15 additions & 3 deletions indra/llprimitive/llmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) // <SK:Nexii> Mesh uploader pivot
{
LLSD mdl;

Expand Down Expand Up @@ -665,6 +666,15 @@ LLSD LLModel::writeModel(
model[LLModel::LOD_PHYSICS] = NULL;
}

// <SK:Nexii> 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();
}
// </SK:Nexii>

if (as_slm)
{ //save material list names
for (U32 i = 0; i < high->mMaterialList.size(); ++i)
Expand Down Expand Up @@ -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();
// <SK:Nexii> 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();
// </SK:Nexii>
mdl[model_names[idx]][i]["NormalizedScale"] = face.mNormalizedScale.getValue();

mdl[model_names[idx]][i]["Position"] = verts;
Expand Down
3 changes: 2 additions & 1 deletion indra/llprimitive/llmodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -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); // <SK:Nexii> Mesh uploader pivot: shifts the stored domains off the prim's origin
Comment thread
Martin-Pitt marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

static LLSD writeModelToStream(
std::ostream& ostr,
Expand Down
144 changes: 142 additions & 2 deletions indra/newview/llfloatermodelpreview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

#include "llfloatermodelpreview.h"

#include "llradiogroup.h" // <SK:Nexii> Mesh uploader pivot

#include "llfilepicker.h"
#include "llimagebmp.h"
#include "llimagetga.h"
Expand Down Expand Up @@ -191,6 +193,16 @@ bool LLFloaterModelPreview::postBuild()

childSetCommitCallback("import_scale", onImportScaleCommit, this);
childSetCommitCallback("pelvis_offset", onPelvisOffsetCommit, this);
// <SK:Nexii> Mesh uploader pivot
for (const char* axis : { "pivot_x", "pivot_y", "pivot_z" })
{
getChild<LLUICtrl>(axis)->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotCommit, this));
}
childSetValue("pivot_units", "percent");
getChild<LLUICtrl>("pivot_units")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotUnitsCommit, this));
childSetValue("pivot_mode", "file");
getChild<LLUICtrl>("pivot_mode")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onPivotModeCommit, this));
// </SK:Nexii>

getChild<LLLineEditor>("description_form")->setKeystrokeCallback(boost::bind(&LLFloaterModelPreview::onDescriptionKeystroke, this, _1), NULL);

Expand All @@ -200,6 +212,7 @@ bool LLFloaterModelPreview::postBuild()
getChild<LLCheckBoxCtrl>("show_skin_weight")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onShowSkinWeightChecked, this, _1));
getChild<LLCheckBoxCtrl>("show_joint_overrides")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1));
getChild<LLCheckBoxCtrl>("show_joint_positions")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1));
getChild<LLCheckBoxCtrl>("show_origin")->setCommitCallback(boost::bind(&LLFloaterModelPreview::onViewOptionChecked, this, _1)); // <SK:Nexii> Mesh uploader pivot

childDisable("upload_skin");
childDisable("upload_joints");
Expand Down Expand Up @@ -495,6 +508,7 @@ void LLFloaterModelPreview::onClickCalculateBtn()
clearLogTab();
addStringToLog("Calculating model data.", false);
mModelPreview->rebuildUploadData();
mModelPreview->updateStatusMessages(); // <SK:Nexii> 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();
Expand All @@ -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<LLWholeModelUploadObserver>(), mModelPreview->getUploadPositionOffsets()); // <SK:Nexii> Mesh uploader pivot

toggleCalculateButton(false);
mUploadBtn->setEnabled(false);
Expand Down Expand Up @@ -675,6 +690,120 @@ void LLFloaterModelPreview::onImportScaleCommit(LLUICtrl*,void* userdata)

fp->mModelPreview->refresh();
}

// <SK:Nexii> 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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<LLRadioGroup>("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<LLSpinCtrl>(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);
}
}
// </SK:Nexii>

//static
void LLFloaterModelPreview::onPelvisOffsetCommit( LLUICtrl*, void* userdata )
{
Expand Down Expand Up @@ -1722,6 +1851,7 @@ void LLFloaterModelPreview::onUpload(void* user_data)
mp->mUploadBtn->setEnabled(false);

mp->mModelPreview->rebuildUploadData();
mp->mModelPreview->updateStatusMessages(); // <SK:Nexii> 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();
Expand All @@ -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<LLWholeModelFeeObserver>(), mp->getWholeModelUploadObserverHandle());
true, LLHandle<LLWholeModelFeeObserver>(), mp->getWholeModelUploadObserverHandle(),
mp->mModelPreview->getUploadPositionOffsets()); // <SK:Nexii> Mesh uploader pivot
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}


Expand Down Expand Up @@ -1873,6 +2004,15 @@ void LLFloaterModelPreview::resetDisplayOptions()
void LLFloaterModelPreview::resetUploadOptions()
{
childSetValue("import_scale", 1);
// <SK:Nexii> Mesh uploader pivot
childSetValue("pivot_units", "percent");
if (mModelPreview)
{
mModelPreview->setPivotMode(LLModelPreview::PIVOT_FILE);
mModelPreview->setCustomPivot(LLVector3::zero);
}
updatePivotControls();
// </SK:Nexii>
childSetValue("pelvis_offset", 0);
childSetValue("physics_explode", 0);
childSetValue("physics_file", "");
Expand Down
7 changes: 7 additions & 0 deletions indra/newview/llfloatermodelpreview.h
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ class LLFloaterModelPreview final : public LLFloaterModelUploadBase
void onDescriptionKeystroke(LLUICtrl*);

static void onImportScaleCommit(LLUICtrl*, void*);
// <SK:Nexii> 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;
// </SK:Nexii>
static void onPelvisOffsetCommit(LLUICtrl*, void*);

static void onPreviewLODCommit(LLUICtrl*,void*);
Expand Down
Loading
Loading