Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
172 changes: 163 additions & 9 deletions src/EditModeController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,122 @@
#include <algorithm>
#include <limits>

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<int>& targets)
{
float cap = std::numeric_limits<float>::infinity();
std::set<int> selected(targets.begin(), targets.end());

for (int v : targets) {
if (v < 0 || v >= static_cast<int>(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) {

Check warning on line 89 in src/EditModeController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this loop so that it is less error-prone.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ296B3kTs--MWcGzzV5&open=AZ296B3kTs--MWcGzzV5&pullRequest=306
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;

Check warning on line 96 in src/EditModeController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "budget" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ29ZR7DksIvWbIGLmec&open=AZ29ZR7DksIvWbIGLmec&pullRequest=306
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) {

Check warning on line 117 in src/EditModeController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this loop so that it is less error-prone.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ296B3kTs--MWcGzzV7&open=AZ296B3kTs--MWcGzzV7&pullRequest=306
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;

Check failure on line 125 in src/EditModeController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ296B3kTs--MWcGzzV6&open=AZ296B3kTs--MWcGzzV6&pullRequest=306
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<std::pair<int,int>>& targets)
{
float cap = std::numeric_limits<float>::infinity();

for (const auto& [v1, v2] : targets) {
if (v1 < 0 || v2 < 0) continue;
if (v1 >= static_cast<int>(hm.vertexCount())) continue;
if (v2 >= static_cast<int>(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()
Expand Down Expand Up @@ -1177,7 +1293,8 @@
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;
Expand Down Expand Up @@ -1696,7 +1813,10 @@
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;
Expand Down Expand Up @@ -1779,6 +1899,23 @@
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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const bool applied = (s.kind == BevelSession::Edges)
? applyBevelTopology(s.targetEdges, s.width)
: applyBevelVertexTopology(s.targetVertices, s.width);
Comment on lines 1896 to 1917

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp initial s.width to the computed s.maxWidth.

s.width is hard-coded to 0.05f on line 1896 and then passed to applyBevel* on lines 1915–1917 without being reconciled against the cap just computed above. On a small mesh where s.maxWidth happens to be below 0.05f, the bevel algorithm internally clamps the applied width (so the mesh looks right) but the session still records width = 0.05f. The first drag then starts with startWidth > s.maxWidth, so updateBevelFromDrag enters its capped branch immediately and the user has to drag through a dead zone (roughly startWidth - maxWidth worth of motion) before the bevel visibly responds to shrinking, and the handle jumps off its default tip position on the very first frame.

🛠️ Proposed fix — reconcile initial width with the cap
     {
         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;
+            if (cap > 0.0f && std::isfinite(cap)) {
+                s.maxWidth = cap;
+                if (s.width > s.maxWidth) s.width = s.maxWidth;
+            }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
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;
if (s.width > s.maxWidth) s.width = s.maxWidth;
}
}
}
const bool applied = (s.kind == BevelSession::Edges)
? applyBevelTopology(s.targetEdges, s.width)
: applyBevelVertexTopology(s.targetVertices, s.width);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/EditModeController.cpp` around lines 1896 - 1917, The initial s.width
(set to 0.05f) must be clamped to the computed cap so the session's startWidth
never exceeds s.maxWidth; after computing s.maxWidth via
computeVertexBevelCap/computeEdgeBevelCap (the block that constructs
HalfEdgeMesh hm and calls buildFromEditableMesh), set s.width =
std::min(s.width, s.maxWidth) (or equivalent) before calling applyBevelTopology
/ applyBevelVertexTopology so the recorded session width matches the actual
applied/clamped width.

Expand Down Expand Up @@ -1853,12 +1990,23 @@
// 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);
}

Expand Down Expand Up @@ -1980,7 +2128,10 @@
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),
Expand All @@ -2002,7 +2153,10 @@
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);
Expand Down
7 changes: 7 additions & 0 deletions src/EditModeController.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ THE SOFTWARE.
#include <QPoint>
#include <QRect>
#include <QVariantList>
#include <limits>
#include <memory>
#include <set>
#include <map>
Expand Down Expand Up @@ -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<float>::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.
Expand Down
28 changes: 22 additions & 6 deletions src/HalfEdgeMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2915,11 +2915,17 @@ std::vector<int> 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;
Expand All @@ -2929,11 +2935,19 @@ std::vector<int> 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;
}

Expand Down Expand Up @@ -3107,7 +3121,9 @@ std::vector<int> 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;
Expand Down
8 changes: 8 additions & 0 deletions src/HalfEdgeMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@ class HalfEdgeMesh

int m_subMeshCount = 0;
std::vector<std::string> 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
15 changes: 7 additions & 8 deletions src/HalfEdgeMesh_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ogre::Vector3> onSharedEdge;
for (int v : newVerts) {
const auto& p = he.vertex(v).position;
Expand All @@ -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) {
Expand Down
24 changes: 19 additions & 5 deletions src/OgreWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return true;
}
Expand Down
Loading
Loading