diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index da25e55f4..4ec879817 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -46,6 +46,122 @@ THE SOFTWARE. #include #include +namespace { + +// Walk-guard budget: a manifold vertex's fan will close well under this. +constexpr int kHERotateMaxSteps = 1024; + +// Rotate one step around a vertex along its outgoing half-edge fan: +// `he` → prev → twin. Returns -1 when the rotation hits a boundary +// (no twin) or when the walker loops back to `startHE`. Callers use +// the -1 sentinel as a "stop iterating" signal, which flattens what +// would otherwise be a nested prev/twin/sentinel triple in every +// half-edge fan walker. +int nextAroundVertex(const HalfEdgeMesh& hm, int he, int startHE) +{ + if (he < 0) return -1; + int prev = hm.halfEdge(he).prev; + if (prev < 0) return -1; + int twin = hm.halfEdge(prev).twin; + if (twin < 0 || twin == startHE) return -1; + return twin; +} + +// Per-vertex max-width for a prospective multi-vertex bevel. Mirrors +// HalfEdgeMesh::bevelVertices's pre-budget formula so the drag gizmo +// caps at the same scalar the algorithm itself will apply: +// - shared edges (both endpoints selected) → 0.999 × edgeLen × 0.5 +// - unshared edges → 0.499 × edgeLen (the single-vertex safety clamp) +// The min across all incident edges of a target is that vertex's cap; +// the min across all targets is the session cap. +float computeVertexBevelCap(const HalfEdgeMesh& hm, + const std::vector& targets) +{ + float cap = std::numeric_limits::infinity(); + std::set selected(targets.begin(), targets.end()); + + for (int v : targets) { + if (v < 0 || v >= static_cast(hm.vertexCount())) continue; + int startHE = hm.vertex(v).halfEdge; + if (startHE < 0) continue; + + int he = startHE; + for (int guard = 0; he >= 0 && guard < kHERotateMaxSteps; ++guard) { + const int n = hm.halfEdge(he).vertex; + const float edgeLen = + hm.vertex(v).position.distance(hm.vertex(n).position); + const float budget = selected.count(n) + ? edgeLen * 0.999f * 0.5f + : edgeLen * 0.499f; + if (budget < cap) cap = budget; + he = nextAroundVertex(hm, he, startHE); + } + } + return cap; +} + +// Shrink `shortestAdj` against one of the two faces adjacent to the +// (va, vb) edge. Walks outgoing half-edges from `va` looking for one +// where `.vertex == vb`; when found, picks the face's opposite +// vertex (the third of a triangle) and clamps `shortestAdj` by both +// va→opp and vb→opp. Returns immediately after a hit — call this +// twice, once from each endpoint, to cover both adjacent faces. +void shrinkEdgeBevelAdjacency(const HalfEdgeMesh& hm, + int va, int vb, + float& shortestAdj) +{ + int startHE = hm.vertex(va).halfEdge; + if (startHE < 0) return; + + int he = startHE; + for (int guard = 0; he >= 0 && guard < kHERotateMaxSteps; ++guard) { + if (hm.halfEdge(he).vertex == vb) { + // Found the va→vb HE; scan the face's other HEs for the + // opposite vertex (the non-va, non-vb one). + for (int loopHE = hm.halfEdge(he).next; + loopHE != he; + loopHE = hm.halfEdge(loopHE).next) { + const int target = hm.halfEdge(loopHE).vertex; + if (target == va || target == vb) continue; + shortestAdj = std::min(shortestAdj, + hm.vertex(va).position.distance(hm.vertex(target).position)); + shortestAdj = std::min(shortestAdj, + hm.vertex(vb).position.distance(hm.vertex(target).position)); + break; + } + return; + } + he = nextAroundVertex(hm, he, startHE); + } +} + +// Per-edge max-width for a prospective multi-edge bevel. Mirrors +// HalfEdgeMesh::bevelEdges::effectiveWidth — 0.4 × shortestAdj, where +// shortestAdj is the min of (edge length, va→opp, vb→opp) across both +// adjacent faces. +float computeEdgeBevelCap(const HalfEdgeMesh& hm, + const std::vector>& targets) +{ + float cap = std::numeric_limits::infinity(); + + for (const auto& [v1, v2] : targets) { + if (v1 < 0 || v2 < 0) continue; + if (v1 >= static_cast(hm.vertexCount())) continue; + if (v2 >= static_cast(hm.vertexCount())) continue; + + float shortestAdj = hm.vertex(v1).position.distance(hm.vertex(v2).position); + // v1 → v2 finds f1; v2 → v1 finds f2. + shrinkEdgeBevelAdjacency(hm, v1, v2, shortestAdj); + shrinkEdgeBevelAdjacency(hm, v2, v1, shortestAdj); + + const float budget = shortestAdj * 0.4f; + if (budget < cap) cap = budget; + } + return cap; +} + +} // namespace + EditModeController* EditModeController::m_pSingleton = nullptr; EditModeController::EditModeController() @@ -1177,7 +1293,8 @@ bool EditModeController::extrudeSelection() if (m_selectionMode != FaceMode) return false; - SentryReporter::addBreadcrumb("edit_mode", "Extrude selection"); + SentryReporter::addBreadcrumb("edit_mode", + QString("Extrude selection (faces=%1)").arg(m_selectedFaces.size())); // Snapshot for undo EditableMesh oldMesh; @@ -1696,7 +1813,10 @@ bool EditModeController::beginBevel() const bool vertValid = (m_selectionMode == VertexMode && !m_selectedVertices.empty()); if (!edgeValid && !vertValid) return false; - SentryReporter::addBreadcrumb("edit_mode", "Bevel: begin session"); + SentryReporter::addBreadcrumb("edit_mode", + QString("Bevel: begin session (%1=%2)") + .arg(edgeValid ? "edges" : "vertices") + .arg(edgeValid ? m_selectedEdges.size() : m_selectedVertices.size())); BevelSession s; s.kind = edgeValid ? BevelSession::Edges : BevelSession::Vertices; @@ -1779,6 +1899,23 @@ bool EditModeController::beginBevel() s.axis = (normalSum.length() > 1e-6f) ? normalSum.normalisedCopy() : Ogre::Vector3::UNIT_Y; s.width = 0.05f; // 2.5% of a 2-unit cube — visible initial chamfer + // Compute the largest width the bevel algorithm will actually apply + // before its internal per-vertex/per-edge clamp takes over, so the + // drag handler can freeze the gizmo at the same scalar the topology + // op would freeze at. See the named helpers at the top of this file + // (computeVertexBevelCap / computeEdgeBevelCap) for the exact + // formulas, which mirror HalfEdgeMesh::bevelVertices and + // HalfEdgeMesh::bevelEdges respectively. + { + HalfEdgeMesh hm; + if (hm.buildFromEditableMesh(*m_editableMesh)) { + const float cap = (s.kind == BevelSession::Vertices) + ? computeVertexBevelCap(hm, s.targetVertices) + : computeEdgeBevelCap(hm, s.targetEdges); + if (cap > 0.0f && std::isfinite(cap)) s.maxWidth = cap; + } + } + const bool applied = (s.kind == BevelSession::Edges) ? applyBevelTopology(s.targetEdges, s.width) : applyBevelVertexTopology(s.targetVertices, s.width); @@ -1853,12 +1990,23 @@ void EditModeController::updateBevelFromDrag(const Ogre::Ray& startRay, // delta (drag away from the mesh) grows the bevel; negative shrinks it. float newWidth = startWidth + delta; if (newWidth < 1e-4f) newWidth = 1e-4f; + + // Cap against the pre-computed per-session maximum so the shaft/handle + // don't slide past the point where the bevel algorithm's own clamp + // stops updating the mesh. Without this, the gizmo visibly grows + // while the bevel is frozen — a confusing UX signal. + bool capped = false; + if (std::isfinite(m_bevelSession.maxWidth) && newWidth > m_bevelSession.maxWidth) { + newWidth = m_bevelSession.maxWidth; + capped = true; + } updateBevelWidth(newWidth); - // Slide the handle cube along the shaft so it visually follows the drag. - // Base offset of 0.1 (the initial shaft tip) plus delta keeps the visible - // handle under the cursor. 0.02 minimum keeps it barely above the shaft - // base so it doesn't sink into the mesh when width is tiny. - float handleLocalY = std::max(0.02f, 0.4f + delta); + + // Compute the handle position from the *effective* width delta so the + // visual shaft length matches the applied bevel. When capped, delta is + // pinned to (maxWidth - startWidth) so the handle freezes too. + float effectiveDelta = capped ? (newWidth - startWidth) : delta; + float handleLocalY = std::max(0.02f, 0.4f + effectiveDelta); m_bevelGizmo->setHandleOffset(handleLocalY); } @@ -1980,7 +2128,10 @@ void EditModeController::commitBevel() if (!m_bevelSession.active) return; - SentryReporter::addBreadcrumb("edit_mode", "Bevel: commit"); + SentryReporter::addBreadcrumb("edit_mode", + QString("Bevel: commit (width=%1, segments=%2)") + .arg(m_bevelSession.width, 0, 'f', 4) + .arg(m_bevelSession.segments)); auto* cmd = new EditMeshTopologyCommand( std::move(m_bevelSession.originalSubMeshes), @@ -2002,7 +2153,10 @@ void EditModeController::cancelBevel() if (!m_bevelSession.active) return; - SentryReporter::addBreadcrumb("edit_mode", "Bevel: cancel"); + SentryReporter::addBreadcrumb("edit_mode", + QString("Bevel: cancel (width=%1, segments=%2)") + .arg(m_bevelSession.width, 0, 'f', 4) + .arg(m_bevelSession.segments)); m_editableMesh->subMeshes() = std::move(m_bevelSession.originalSubMeshes); m_selectedVertices = std::move(m_bevelSession.origSelectedVertices); diff --git a/src/EditModeController.h b/src/EditModeController.h index c37bbc017..2a57558a4 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -34,6 +34,7 @@ THE SOFTWARE. #include #include #include +#include #include #include #include @@ -548,6 +549,12 @@ private slots: Ogre::Vector3 pivot = Ogre::Vector3::ZERO; ///< Gizmo pivot (chamfer region center). Ogre::Vector3 axis = Ogre::Vector3::UNIT_Y; ///< Gizmo axis (averaged surface normal). float width = 0.0f; ///< Currently-applied width. + // Computed at session start: the maximum width the bevel algorithm + // will actually use before its internal clamp kicks in. The drag + // handler clamps `width` — and the gizmo shaft/handle — against + // this so the visible shaft stops growing the instant the bevel + // caps, instead of the handle drifting past the capped bevel. + float maxWidth = std::numeric_limits::infinity(); int segments = 1; ///< Chamfer-strip segment count. /// Per-interior-point profile values (size = segments-1, each in /// [0, 1], 0.5 = flat). Empty when segments == 1. diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 4b69f6a63..0e1552b8f 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -2915,11 +2915,17 @@ std::vector HalfEdgeMesh::bevelVertices( int n = m_halfEdges[he].vertex; const float edgeLen = m_vertices[v].position.distance(m_vertices[n].position); - // If the far endpoint is also selected, each end owns - // half the edge; otherwise the whole shortest-edge- - // halves rule (match single-vertex clamp behaviour). - float share = selected.count(n) ? 0.5f : 1.0f; - float budget = edgeLen * 0.49f * share; + // Shared edges (both endpoints selected) split 50/50 with + // a near-full ceiling — each side reaches ~0.4995 × edgeLen, + // meeting the neighbour's bevel near the midpoint. Unshared + // edges use the same 0.499 × edgeLen safety clamp the + // single-vertex path would apply, so disconnected multi- + // vertex selections don't over-reach when + // m_skipVertexBevelClamp (set below) bypasses the single- + // vertex re-clamp. + float budget = selected.count(n) + ? edgeLen * 0.999f * 0.5f + : edgeLen * 0.499f; if (budget < minBudget) minBudget = budget; int prev = m_halfEdges[he].prev; int twin = m_halfEdges[prev].twin; @@ -2929,11 +2935,19 @@ std::vector HalfEdgeMesh::bevelVertices( } if (minBudget < width) perVertexWidth[i] = minBudget; } + // Tell the single-vertex path to honor our pre-budgeted widths + // verbatim. Otherwise the single-vertex clamp re-clamps against + // the mutated mesh's edge lengths (prior bevels have shortened + // the shared edges), which produces asymmetric offsets. Flag is + // reset after the loop so a later top-level bevelVertices call + // starts with the usual safety clamp enabled. + m_skipVertexBevelClamp = true; for (size_t i = 0; i < vertexIndices.size(); ++i) { auto added = bevelVertices({vertexIndices[i]}, perVertexWidth[i], segments, profile, profilePointsIn); newVertices.insert(newVertices.end(), added.begin(), added.end()); } + m_skipVertexBevelClamp = false; return newVertices; } @@ -3107,7 +3121,9 @@ std::vector HalfEdgeMesh::bevelVertices( float d = m_vertices[v].position.distance(m_vertices[tgt].position); if (d < minEdgeLen) minEdgeLen = d; } - const float offset = std::min(width, 0.49f * minEdgeLen); + const float offset = m_skipVertexBevelClamp + ? width + : std::min(width, 0.499f * minEdgeLen); if (offset <= 1e-6f) continue; VertBevelPlan plan; diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index 2179cb23f..09056e018 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -428,6 +428,14 @@ class HalfEdgeMesh int m_subMeshCount = 0; std::vector m_materialNames; + + // When true, bevelVertices trusts the caller's width and skips its + // per-vertex "min(width, 0.499 × minEdgeLen)" safety clamp. The + // multi-vertex pre-budgeted path sets this for its inner recursive + // single-vertex call so pre-budgeted values aren't re-clamped + // against a mutated mesh's edge lengths; it resets to false + // afterwards. + bool m_skipVertexBevelClamp = false; }; #endif // HALFEDGEMESH_H diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 31924b5ac..30b92e9f1 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -2952,14 +2952,14 @@ TEST(HalfEdgeMeshStandalone, BevelVertexSymmetricBudgetOnSharedEdge) { ASSERT_TRUE(he.buildFromEditableMesh(em)); // v4 = (-1, 1, 1), v5 = (1, 1, 1). Edge length = 2. Request a // width that would exceed half (i.e., collision territory). - const float requested = 100.0f; // huge → clamped to 0.49 * 1.0 = 0.49 + const float requested = 100.0f; // huge → pre-budget caps each at 0.999 auto newVerts = he.bevelVertices({4, 5}, requested); EXPECT_TRUE(he.validate()); // Distance from v4 to v4's offsets should equal v5 to v5's offsets - // along the shared edge (both = 0.49 * 1.0 = 0.49). Pick the two - // offsets on the v4-v5 edge: they're the ones with y=1, z=1 and - // x in (-1, 1) range. + // along the shared edge. Each bevel reaches 0.999 × (edgeLen × share) + // = 0.999 × (2 × 0.5) = 0.999 from its corner, so the two offsets + // meet near the midpoint with a ~0.002-wide sliver between them. std::vector onSharedEdge; for (int v : newVerts) { const auto& p = he.vertex(v).position; @@ -2971,13 +2971,12 @@ TEST(HalfEdgeMeshStandalone, BevelVertexSymmetricBudgetOnSharedEdge) { } ASSERT_EQ(onSharedEdge.size(), 2u); const Ogre::Vector3 v4(-1, 1, 1), v5(1, 1, 1); - // Each vertex's offset should be 0.49 along the shared edge. float dist4 = std::min(onSharedEdge[0].distance(v4), onSharedEdge[1].distance(v4)); float dist5 = std::min(onSharedEdge[0].distance(v5), onSharedEdge[1].distance(v5)); - EXPECT_NEAR(dist4, dist5, 1e-4f) + EXPECT_NEAR(dist4, dist5, 1e-3f) << "offsets at each end of shared edge should be symmetric"; - EXPECT_NEAR(dist4, 0.49f, 1e-3f) - << "each side should claim half the 1.0 shared half-edge budget"; + EXPECT_NEAR(dist4, 0.999f, 2e-3f) + << "each side should reach to the midpoint of the 2-unit shared edge"; } TEST(HalfEdgeMeshStandalone, BevelVertexOffsetIsClampedToHalfEdge) { diff --git a/src/OgreWidget.cpp b/src/OgreWidget.cpp index 6cf75181f..28901fa13 100755 --- a/src/OgreWidget.cpp +++ b/src/OgreWidget.cpp @@ -196,11 +196,25 @@ bool OgreWidget::frameStarted(const Ogre::FrameEvent& e) // with multiple viewports, every camera rescales the same gizmo per // frame and the last-registered frame listener wins. auto* transform = TransformOperator::getSingleton(); - if (mCamera && mCamera->getCamera() && transform - && transform->getActiveWidget() == this) { - auto* camera = mCamera->getCamera(); - EditModeController::instance()->tickBevelGizmo(camera); - transform->tickTransformGizmoScale(camera); + if (mCamera && mCamera->getCamera() && transform) { + // Gate: tick when this viewport is the registered active one, OR + // when no viewport has been activated yet (first render before + // the user clicks anywhere). Without this fallback the gizmo + // renders at its authored 1.0 scale for the first frame after + // the user picks Translate/Rotate/Scale via the toolbar — they + // see a tiny gizmo that pops to correct size only after their + // first viewport click. + // + // Restrict the null-active fallback to a single deterministic + // viewport (index 0) so multi-viewport layouts don't race N + // cameras rescaling the shared gizmo singleton each frame. + const auto* active = transform->getActiveWidget(); + const bool isInitialFallbackViewport = (active == nullptr && getIndex() == 0); + if (active == this || isInitialFallbackViewport) { + auto* camera = mCamera->getCamera(); + EditModeController::instance()->tickBevelGizmo(camera); + transform->tickTransformGizmoScale(camera); + } } return true; } diff --git a/src/ScaleGizmo.cpp b/src/ScaleGizmo.cpp index 4a8fe56f4..c58239df2 100644 --- a/src/ScaleGizmo.cpp +++ b/src/ScaleGizmo.cpp @@ -98,14 +98,17 @@ void ScaleGizmo::createZaxis(const Ogre::ColourValue& colour) void ScaleGizmo::createSolidXaxis(const Ogre::ColourValue& colour) { float thickness = mScale / mSolidThickness; - float shaftLength = mScale * 0.85f; + // Negative shaft length so the handle visually sits on the screen + // right. See TranslationGizmo::createSolidXaxis for the rationale + // (camera looks from -Z toward +Z; world +X lands on screen-left). + float shaftLength = -mScale * 0.85f; float cubeHalf = thickness * mCubeSize; m_pXaxis->clear(); m_pXaxis->begin(GUI_MATERIAL_NAME, Ogre::RenderOperation::OT_TRIANGLE_LIST); m_pXaxis->colour(colour); - // Shaft - thin box from origin to shaftLength along +X + // Shaft - thin box from origin to shaftLength along -X m_pXaxis->position(Ogre::Vector3(0, thickness, thickness)); m_pXaxis->position(Ogre::Vector3(0, -thickness, thickness)); m_pXaxis->position(Ogre::Vector3(shaftLength, -thickness, thickness)); @@ -116,17 +119,25 @@ void ScaleGizmo::createSolidXaxis(const Ogre::ColourValue& colour) m_pXaxis->position(Ogre::Vector3(shaftLength, -thickness, -thickness)); m_pXaxis->position(Ogre::Vector3(shaftLength, thickness, -thickness)); - m_pXaxis->quad(0, 1, 2, 3); - m_pXaxis->quad(7, 6, 5, 4); - m_pXaxis->quad(0, 3, 7, 4); - m_pXaxis->quad(2, 1, 5, 6); - m_pXaxis->quad(3, 2, 6, 7); - m_pXaxis->quad(1, 0, 4, 5); + // Shaft quads (winding flipped for the -X orientation) + m_pXaxis->quad(3, 2, 1, 0); + m_pXaxis->quad(4, 5, 6, 7); + m_pXaxis->quad(4, 7, 3, 0); + m_pXaxis->quad(6, 5, 1, 2); + m_pXaxis->quad(7, 6, 2, 3); + m_pXaxis->quad(5, 4, 0, 1); - // Cube handle at end of shaft - createCube(m_pXaxis, colour, Ogre::Vector3(mScale, 0, 0), cubeHalf); + // Cube handle at end of shaft (-X side) + createCube(m_pXaxis, colour, Ogre::Vector3(-mScale, 0, 0), cubeHalf); m_pXaxis->end(); + + // Re-assert the pickable bbox after the rebuild — ManualObject::end() + // computes an auto-bbox from vertex extents, which is tight to the + // shaft-and-cube geometry but doesn't include the gap between them. + // Explicit bbox matches what the user actually sees. + m_pXaxis->setBoundingBox(GizmoAxisHelpers::makeAxisBoundingBox( + GizmoAxisHelpers::Axis::X, -(mScale + cubeHalf), 0.0f, cubeHalf)); } void ScaleGizmo::createSolidYaxis(const Ogre::ColourValue& colour) @@ -161,6 +172,8 @@ void ScaleGizmo::createSolidYaxis(const Ogre::ColourValue& colour) createCube(m_pYaxis, colour, Ogre::Vector3(0, mScale, 0), cubeHalf); m_pYaxis->end(); + m_pYaxis->setBoundingBox(GizmoAxisHelpers::makeAxisBoundingBox( + GizmoAxisHelpers::Axis::Y, 0.0f, mScale + cubeHalf, cubeHalf)); } void ScaleGizmo::createSolidZaxis(const Ogre::ColourValue& colour) @@ -195,6 +208,8 @@ void ScaleGizmo::createSolidZaxis(const Ogre::ColourValue& colour) createCube(m_pZaxis, colour, Ogre::Vector3(0, 0, mScale), cubeHalf); m_pZaxis->end(); + m_pZaxis->setBoundingBox(GizmoAxisHelpers::makeAxisBoundingBox( + GizmoAxisHelpers::Axis::Z, 0.0f, mScale + cubeHalf, cubeHalf)); } ////////////////////////////////////////// @@ -289,9 +304,22 @@ void ScaleGizmo::createAxis(void) GizmoAxisHelpers::forEachAxisIndexed(m_pXaxis, m_pYaxis, m_pZaxis, [this, cubeHalf](GizmoAxisHelpers::Axis axis, Ogre::ManualObject* axisObject) { + // X geometry is drawn along -X (see + // createSolidXaxis) to match the camera's view + // flip; its bbox mirrors that range. Y/Z keep + // the original [0, mScale + cubeHalf] extents. + Ogre::Real axisMin; + Ogre::Real axisMax; + if (axis == GizmoAxisHelpers::Axis::X) { + axisMin = -(mScale + cubeHalf); + axisMax = 0.0f; + } else { + axisMin = 0.0f; + axisMax = mScale + cubeHalf; + } axisObject->setBoundingBox( GizmoAxisHelpers::makeAxisBoundingBox( - axis, 0.0f, mScale + cubeHalf, cubeHalf)); + axis, axisMin, axisMax, cubeHalf)); }); mHighlighted = false; diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp index 48390ebac..a29616583 100644 --- a/src/SceneTreeModel.cpp +++ b/src/SceneTreeModel.cpp @@ -108,6 +108,17 @@ void SceneTreeModel::buildChildren(Ogre::SceneNode* sceneNode, SceneTreeItem* pa if (name.isEmpty() || Manager::getSingleton()->isForbiddenNodeName(name)) continue; + // Hide transient gizmo scaffolding that editor code creates as + // children of the root scene node (currently: BevelGizmo's handle + // rig). Matched on the exact names the gizmo creates — see + // BevelGizmo.cpp — so a user mesh sharing the suffix doesn't + // vanish from the tree. A future gizmo with different names + // needs to add its own entries here. + if (name == "BevelGizmo_Node" + || name == "BevelGizmo_Shaft" + || name == "BevelGizmo_Handle") + continue; + auto* nodeItem = new SceneTreeItem(name, SceneTreeItem::Node, childNode, parentItem); parentItem->appendChild(nodeItem); diff --git a/src/TranslationGizmo.cpp b/src/TranslationGizmo.cpp index 36ff3245c..03d7ec684 100755 --- a/src/TranslationGizmo.cpp +++ b/src/TranslationGizmo.cpp @@ -71,7 +71,13 @@ void TranslationGizmo::createZaxis(const Ogre::ColourValue& colour) void TranslationGizmo::createSolidXaxis(const Ogre::ColourValue& colour) { float thickness = mScale / mSolidThickness; - float shaftLength = mScale * 0.85f; // Arrow shaft ends at 85% to make room for arrow head + // Negative shaft length so the arrow visually points toward world -X. + // The viewport camera is set up at world -Z looking toward +Z, which + // makes world +X appear on screen-left. Pointing geometry toward -X + // makes the arrow appear on screen-right, matching user expectation. + // Drag/pick math use UNIT_X internally and already produce screen- + // consistent motion, so only the visual needs flipping. + float shaftLength = -mScale * 0.85f; float headBaseRadius = thickness * 2.5f; // Arrow head base is 2.5x thicker than shaft m_pXaxis->clear(); @@ -89,34 +95,45 @@ void TranslationGizmo::createSolidXaxis(const Ogre::ColourValue& colour) m_pXaxis->position(Ogre::Vector3(shaftLength, -thickness, -thickness)); m_pXaxis->position(Ogre::Vector3(shaftLength, thickness, -thickness)); - // Arrow shaft quads - m_pXaxis->quad(0, 1, 2, 3); - m_pXaxis->quad(7, 6, 5, 4); - m_pXaxis->quad(0, 3, 7, 4); - m_pXaxis->quad(2, 1, 5, 6); - m_pXaxis->quad(3, 2, 6, 7); - m_pXaxis->quad(1, 0, 4, 5); - - // Arrow head - pyramid pointing in +X direction + // Arrow shaft quads (winding flipped because shaft runs toward -X) + m_pXaxis->quad(3, 2, 1, 0); + m_pXaxis->quad(4, 5, 6, 7); + m_pXaxis->quad(4, 7, 3, 0); + m_pXaxis->quad(6, 5, 1, 2); + m_pXaxis->quad(7, 6, 2, 3); + m_pXaxis->quad(5, 4, 0, 1); + + // Arrow head - pyramid pointing in -X direction int headBaseIdx = 8; // Base of arrow head (square at shaftLength) m_pXaxis->position(Ogre::Vector3(shaftLength, headBaseRadius, headBaseRadius)); m_pXaxis->position(Ogre::Vector3(shaftLength, -headBaseRadius, headBaseRadius)); m_pXaxis->position(Ogre::Vector3(shaftLength, -headBaseRadius, -headBaseRadius)); m_pXaxis->position(Ogre::Vector3(shaftLength, headBaseRadius, -headBaseRadius)); - // Tip of arrow head (at mScale) + // Tip of arrow head (at -mScale) int headTipIdx = 12; - m_pXaxis->position(Ogre::Vector3(mScale, 0, 0)); - - // Arrow head faces (4 triangles forming a pyramid) - m_pXaxis->triangle(headBaseIdx, headBaseIdx+1, headTipIdx); // Right face - m_pXaxis->triangle(headBaseIdx+1, headBaseIdx+2, headTipIdx); // Bottom face - m_pXaxis->triangle(headBaseIdx+2, headBaseIdx+3, headTipIdx); // Left face - m_pXaxis->triangle(headBaseIdx+3, headBaseIdx, headTipIdx); // Top face - // Base quad of arrow head - m_pXaxis->quad(headBaseIdx, headBaseIdx+3, headBaseIdx+2, headBaseIdx+1); + m_pXaxis->position(Ogre::Vector3(-mScale, 0, 0)); + + // Arrow head faces (4 triangles forming a pyramid; winding + // reversed from +X version so outward-facing normals still + // face away from the shaft axis). + m_pXaxis->triangle(headBaseIdx+1, headBaseIdx, headTipIdx); + m_pXaxis->triangle(headBaseIdx+2, headBaseIdx+1, headTipIdx); + m_pXaxis->triangle(headBaseIdx+3, headBaseIdx+2, headTipIdx); + m_pXaxis->triangle(headBaseIdx, headBaseIdx+3, headTipIdx); + // Base quad of arrow head (winding reversed) + m_pXaxis->quad(headBaseIdx+1, headBaseIdx+2, headBaseIdx+3, headBaseIdx); m_pXaxis->end(); + + // Re-assert the pickable bbox after the rebuild. `end()` auto- + // computes a tight bbox from vertex extents, which can differ from + // the explicit bbox the caller set in createAxis. Re-setting here + // keeps picking consistent with the flipped-X geometry on every + // hover rebuild. + const float bbSize = (mScale / mSolidThickness) * 2.5f; + m_pXaxis->setBoundingBox(GizmoAxisHelpers::makeAxisBoundingBox( + GizmoAxisHelpers::Axis::X, -mScale, 0.0f, bbSize)); } void TranslationGizmo::createSolidYaxis(const Ogre::ColourValue& colour) @@ -352,10 +369,20 @@ void TranslationGizmo::createAxis(void) GizmoAxisHelpers::forEachAxisIndexed(m_pXaxis, m_pYaxis, m_pZaxis, [this, bbSize](GizmoAxisHelpers::Axis axis, Ogre::ManualObject* axisObject) { - const Ogre::Real axisMin = - (axis == GizmoAxisHelpers::Axis::Z && mLeftHandCs) ? -mScale : 0.0f; - const Ogre::Real axisMax = - (axis == GizmoAxisHelpers::Axis::Z && mLeftHandCs) ? 0.0f : mScale; + // The X geometry is drawn from 0 toward -mScale (see + // createSolidXaxis) to match the camera's view flip, so + // its bbox must mirror the same [-mScale, 0] extent — + // otherwise the visible arrow and the pickable region + // end up on opposite sides of the origin. + // Flip the bbox to the negative range for the X + // axis (geometry flipped for the camera's view + // flip) and for Z under a left-handed coord + // system (legacy viewport option). + const bool flipAxis = + axis == GizmoAxisHelpers::Axis::X + || (axis == GizmoAxisHelpers::Axis::Z && mLeftHandCs); + const Ogre::Real axisMin = flipAxis ? -mScale : 0.0f; + const Ogre::Real axisMax = flipAxis ? 0.0f : mScale; axisObject->setBoundingBox( GizmoAxisHelpers::makeAxisBoundingBox(axis, axisMin, axisMax, bbSize)); });