Fix rotate manipulator axis and gizmo pivot placement

Rotate manipulator — correct axis bug:
- Object-space delta axis was set to initRot.GetRow3(axis) (a parent-space
  vector) then applied in the prim's local frame via deltaM * initRot.
  These frames are mismatched: the axis must be the canonical e_a so that
  Rot(e_a) fixes e_a and the prim rotates purely about its own axis.
  Fixed to use the canonical axis for both spaces; only composition order
  differs (deltaM * initRot for object, initRot * deltaM for world).

Gizmo pivot placement:
- ComputeGizmoPivot() used the bounding-box centre (geometry centroid),
  placing the gizmo at the wrong location for any non-centred prim.
  Replaced with parentToWorld.Transform(translate + pivot) from
  XformCommonAPI::GetXformVectors — the authored pivot point in world space.
  Fallback for incompatible op stacks uses worldMatrix.ExtractTranslation().

Property panel:
- Show pivot (X/Y/Z) as read-only row below Scale, populated from
  XformCommonAPI::GetXformVectors each frame.
- Removed active-rotate-axis display (was added and then removed per request).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:57:39 +08:00
parent e0b0fd8204
commit 7f0fa6a944
7 changed files with 82 additions and 42 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
#include "Application.h" #include "Application.h"
#include "../utils/Logger.h" #include "../utils/Logger.h"
#include "../utils/FileDialog.h" #include "../utils/FileDialog.h"
#include "../utils/PathUtils.h" #include "../utils/PathUtils.h"
+19
View File
@@ -116,6 +116,7 @@ void PropertyPanel::ReadTransform() {
static_cast<float>(translation[2])); static_cast<float>(translation[2]));
m_rotate = rotation; m_rotate = rotation;
m_scale = scale; m_scale = scale;
m_pivot = pivot;
m_rotOrder = rotOrder; m_rotOrder = rotOrder;
} }
else else
@@ -163,6 +164,7 @@ void PropertyPanel::ReadTransform() {
static_cast<float>(eulerDeg[1]), static_cast<float>(eulerDeg[1]),
static_cast<float>(eulerDeg[2])); static_cast<float>(eulerDeg[2]));
m_rotOrder = UsdGeomXformCommonAPI::RotationOrderXYZ; m_rotOrder = UsdGeomXformCommonAPI::RotationOrderXYZ;
m_pivot = GfVec3f(0.f, 0.f, 0.f);
m_xformFallback = true; m_xformFallback = true;
} }
@@ -500,6 +502,23 @@ void PropertyPanel::RenderTransformSection() {
} }
} }
// ---- Pivot row (read-only) ----
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextColored(kColorLabel, "Pivot");
for (int i = 0; i < 3; ++i) {
ImGui::TableSetColumnIndex(i + 1);
ImGui::SetNextItemWidth(-FLT_MIN);
// Use InputFloat with ReadOnly so the value is visible but not editable.
// Pivot changes require a dedicated pivot-editing mode (not yet supported).
char id[8]; snprintf(id, sizeof(id), "##pv%d", i);
float v = m_pivot[i];
ImGui::PushStyleColor(ImGuiCol_FrameBg,
ImVec4(0.15f, 0.15f, 0.15f, 1.f)); // dimmer background signals read-only
ImGui::InputFloat(id, &v, 0.f, 0.f, "%.3f", ImGuiInputTextFlags_ReadOnly);
ImGui::PopStyleColor();
}
ImGui::EndTable(); ImGui::EndTable();
// Record whether any field was active this frame so Render() can decide // Record whether any field was active this frame so Render() can decide
+2 -1
View File
@@ -59,10 +59,11 @@ private:
pxr::UsdStageRefPtr m_stage; pxr::UsdStageRefPtr m_stage;
std::string m_selectedPrimPath; std::string m_selectedPrimPath;
// Cached TRS values (float matches DragFloat precision) // Cached TRS + pivot values (float matches DragFloat precision)
pxr::GfVec3f m_translate{ 0.f, 0.f, 0.f }; pxr::GfVec3f m_translate{ 0.f, 0.f, 0.f };
pxr::GfVec3f m_rotate { 0.f, 0.f, 0.f }; pxr::GfVec3f m_rotate { 0.f, 0.f, 0.f };
pxr::GfVec3f m_scale { 1.f, 1.f, 1.f }; pxr::GfVec3f m_scale { 1.f, 1.f, 1.f };
pxr::GfVec3f m_pivot { 0.f, 0.f, 0.f };
pxr::UsdGeomXformCommonAPI::RotationOrder pxr::UsdGeomXformCommonAPI::RotationOrder
m_rotOrder{ pxr::UsdGeomXformCommonAPI::RotationOrderXYZ }; m_rotOrder{ pxr::UsdGeomXformCommonAPI::RotationOrderXYZ };
+26 -19
View File
@@ -403,13 +403,11 @@ void TransformManipulator::Render(ImDrawList* dl, const pxr::GfMatrix4d& vp,
// Clock-hand: intersect mouse ray with ring plane (GfPlane) -> rotateTo vector. // Clock-hand: intersect mouse ray with ring plane (GfPlane) -> rotateTo vector.
// worldRotation = GfRotation(dragStartClockHand, currentClockHand). // worldRotation = GfRotation(dragStartClockHand, currentClockHand).
// axisSign = dot(planeNormal, worldRotation.GetAxis()) > 0 ? 1 : -1. // axisSign = dot(planeNormal, worldRotation.GetAxis()) > 0 ? 1 : -1.
// localDeltaAxis: // deltaAxis = canonical axis e_a for the dragged ring (both spaces).
// Object => initRot.GetRow3(dragAxis) [usdtweak exact: local axis in parent space] // deltaRotation = GfRotation(deltaAxis * axisSign, angle).
// World => world unit vector
// deltaRotation = GfRotation(localDeltaAxis * axisSign, angle).
// resultingRotation: // resultingRotation:
// Object => GfMatrix4d(1).SetRotate(delta) * initRot [usdtweak exact] // Object => GfMatrix4d(1).SetRotate(delta) * initRot [delta in prim local frame]
// World => initRot * GfMatrix4d(1).SetRotate(delta) [USD row-vector world delta] // World => initRot * GfMatrix4d(1).SetRotate(delta) [delta about world axis]
// DecomposeRotation with FIXED initRot rows as reference axes + current hints. // DecomposeRotation with FIXED initRot rows as reference axes + current hints.
// //
// SCALE (ScaleManipulator::OnUpdate) // SCALE (ScaleManipulator::OnUpdate)
@@ -494,16 +492,24 @@ bool TransformManipulator::HandleInput(const pxr::GfMatrix4d& vp,
pxr::UsdGeomXformOp::GetOpTransform(opType,pxr::VtValue(rot)); pxr::UsdGeomXformOp::GetOpTransform(opType,pxr::VtValue(rot));
// Capture delta axis and space mode at drag start. // Capture delta axis and space mode at drag start.
// Object: initRot.GetRow3(axis) -- local axis in parent space. //
// World : world unit vector. // The delta axis is the CANONICAL axis e_a for the dragged ring
if (m_transformSpace==TransformSpace::Object) { // in BOTH spaces; only the composition order (below) differs.
m_dragRotateDeltaAxis=m_dragRotateInitialRotMat.GetRow3(m_dragAxis); //
double n=m_dragRotateDeltaAxis.GetLength(); // Object: e_a applied in the prim's own local frame via
if (n>1e-9) m_dragRotateDeltaAxis/=n; // deltaM * initRot. Because Rot(e_a) fixes e_a, the
} else { // prim's current local axis (e_a * initRot) stays put,
static const pxr::GfVec3d kW[3]={{1,0,0},{0,1,0},{0,0,1}}; // so the object rotates purely about its own axis
m_dragRotateDeltaAxis=kW[m_dragAxis]; // (Maya local mode).
} // World : e_a applied after initRot via initRot * deltaM, i.e.
// rotation about the fixed world axis.
//
// NOTE: using initRot.GetRow3(axis) (a parent-space vector) with
// deltaM * initRot is WRONG -- the axis is then applied in the
// local frame it does not belong to, which makes the object
// tumble once it already carries a rotation.
static const pxr::GfVec3d kAxis[3]={{1,0,0},{0,1,0},{0,0,1}};
m_dragRotateDeltaAxis=kAxis[m_dragAxis];
m_dragRotateObjectSpace=(m_transformSpace==TransformSpace::Object); m_dragRotateObjectSpace=(m_transformSpace==TransformSpace::Object);
} }
else if (m_mode==ManipulatorMode::Scale) { else if (m_mode==ManipulatorMode::Scale) {
@@ -584,13 +590,14 @@ bool TransformManipulator::HandleInput(const pxr::GfMatrix4d& vp,
?1.0:-1.0; ?1.0:-1.0;
// usdtweak: const GfRotation deltaRotation(localPlaneNormal*axisSign, angle) // usdtweak: const GfRotation deltaRotation(localPlaneNormal*axisSign, angle)
// deltaAxis is the canonical axis e_a for the dragged ring.
pxr::GfRotation deltaRot(m_dragRotateDeltaAxis*axisSign, pxr::GfRotation deltaRot(m_dragRotateDeltaAxis*axisSign,
worldRotation.GetAngle()); worldRotation.GetAngle());
pxr::GfMatrix4d deltaM=pxr::GfMatrix4d(1.0).SetRotate(deltaRot); pxr::GfMatrix4d deltaM=pxr::GfMatrix4d(1.0).SetRotate(deltaRot);
// usdtweak: resultingRotation = GfMatrix4d(1).SetRotate(delta)*initRot // resultingRotation:
// Object (usdtweak exact): deltaM * initRot // Object (rotate about prim's own axis): deltaM * initRot
// World (USD row-vector) : initRot * deltaM // World (rotate about fixed world axis): initRot * deltaM
pxr::GfMatrix4d result= pxr::GfMatrix4d result=
m_dragRotateObjectSpace m_dragRotateObjectSpace
? deltaM * m_dragRotateInitialRotMat ? deltaM * m_dragRotateInitialRotMat
+8 -12
View File
@@ -108,19 +108,15 @@ private:
// //
// Ring plane: world-space ring normal (for mouse-ray intersection + sign). // Ring plane: world-space ring normal (for mouse-ray intersection + sign).
// //
// Delta-rotation axis differs by TransformSpace: // Delta-rotation axis is the canonical axis e_a for the dragged ring in
// Object => initRot.GetRow3(axis) -- local axis in PARENT space // BOTH spaces; only the composition order differs:
// (exact usdtweak localPlaneNormal convention) // Object => SetRotate(delta) * initRot
// World => world unit vector // delta applied in the prim's own local frame, so the dragged
// (correct for root-level / simple hierarchies) // local axis stays fixed -> rotates purely about its own axis.
// // World => initRot * SetRotate(delta)
// resultingRotation formula: // initRot maps local->parent, then delta about the world axis.
// Object => GfMatrix4d(1).SetRotate(delta) * initRot
// (usdtweak: delta applied in local frame before initRot)
// World => initRot * GfMatrix4d(1).SetRotate(delta)
// (USD row-vector: initRot maps local->parent, then world delta)
pxr::GfVec3d m_dragRotatePlaneNormal; // world-space pxr::GfVec3d m_dragRotatePlaneNormal; // world-space
pxr::GfVec3d m_dragRotateDeltaAxis; // space-dependent (see above) pxr::GfVec3d m_dragRotateDeltaAxis; // canonical axis e_a for ring
pxr::GfVec3d m_dragRotateFrom; // clock-hand at drag start pxr::GfVec3d m_dragRotateFrom; // clock-hand at drag start
pxr::GfMatrix4d m_dragRotateInitialRotMat; // local->parent rot at drag start pxr::GfMatrix4d m_dragRotateInitialRotMat; // local->parent rot at drag start
bool m_dragRotateObjectSpace = true; bool m_dragRotateObjectSpace = true;
+1
View File
@@ -65,6 +65,7 @@ public:
LayoutMode GetLayout() const { return m_layout; } LayoutMode GetLayout() const { return m_layout; }
int GetFocusedTileIndex() const { return m_focusedTileIndex; } int GetFocusedTileIndex() const { return m_focusedTileIndex; }
private: private:
// ── Tile rect helper ───────────────────────────────────────────────────── // ── Tile rect helper ─────────────────────────────────────────────────────
struct TileRect { ImVec2 pos; ImVec2 size; }; struct TileRect { ImVec2 pos; ImVec2 size; };
+25 -9
View File
@@ -130,16 +130,32 @@ pxr::GfVec3d ViewportTile::ComputeGizmoPivot() const
pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedSdfPaths.front()); pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedSdfPaths.front());
if (!prim) return pxr::GfVec3d(0.0); if (!prim) return pxr::GfVec3d(0.0);
pxr::TfTokenVector purposes = {
pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy };
pxr::UsdGeomBBoxCache bboxCache(
m_displayTime, purposes, true);
pxr::GfBBox3d bbox = bboxCache.ComputeWorldBound(prim);
pxr::GfRange3d range = bbox.ComputeAlignedRange();
if (!range.IsEmpty())
return (range.GetMin() + range.GetMax()) * 0.5;
pxr::UsdGeomXformCache xformCache(m_displayTime); pxr::UsdGeomXformCache xformCache(m_displayTime);
// Primary: use XformCommonAPI to read the authored translate and pivot op.
// The manipulator must sit at the pivot's world-space position:
// worldPivot = parentToWorld.Transform(translate + pivot)
// NOT at the bounding-box center (geometry centroid) and NOT at
// worldMatrix.ExtractTranslation() which folds in the pivot-inverse op
// and gives the geometry origin, not the authored pivot point.
pxr::UsdGeomXformCommonAPI api(prim);
pxr::GfVec3d translate;
pxr::GfVec3f rotation, scale, pivot;
pxr::UsdGeomXformCommonAPI::RotationOrder rotOrder;
if (api.GetXformVectors(&translate, &rotation, &scale, &pivot,
&rotOrder, m_displayTime))
{
pxr::GfVec3d localPivot = translate + pxr::GfVec3d(pivot[0], pivot[1], pivot[2]);
pxr::UsdPrim parent = prim.GetParent();
if (parent && !parent.IsPseudoRoot()) {
pxr::GfMatrix4d p2w = xformCache.GetLocalToWorldTransform(parent);
return p2w.Transform(localPivot);
}
return localPivot; // root-level prim: parent space == world space
}
// Fallback for incompatible op stacks (e.g. xformOp:transform matrix ops):
// use the prim's world-space origin from the local-to-world matrix.
pxr::GfMatrix4d worldXform = xformCache.GetLocalToWorldTransform(prim); pxr::GfMatrix4d worldXform = xformCache.GetLocalToWorldTransform(prim);
return worldXform.ExtractTranslation(); return worldXform.ExtractTranslation();
} }