Skip to content
Merged
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
cmake_policy(SET CMP0005 NEW)
cmake_policy(SET CMP0048 NEW) # manages project version

project(QtMeshEditor VERSION 2.28.3 LANGUAGES C CXX)
project(QtMeshEditor VERSION 2.29.1 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
447 changes: 447 additions & 0 deletions src/EditModeController.cpp

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions src/EditModeController.h
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,66 @@
Q_INVOKABLE void resetBevelProfile();
/// @}

/// @name Knife tool
/// @{
/**
* @brief Enter knife mode. The user places cut points along mesh
* surface (left-click), commits with commitKnife (Enter or
* double-click) or cancels with cancelKnife (Esc). While
* active, knifeSessionActive() returns true and the viewport
* draws a live preview of the pending cut line.
*/
Q_INVOKABLE bool beginKnife();

/**
* @brief Record a cut point from a viewport click. The hit-test
* priority is vertex → edge → face (so snaps are sticky at
* geometry boundaries). Called from TransformOperator's
* mouse handler when the knife session is active.
*/
bool addKnifePoint(OgreWidget* widget, int screenX, int screenY);

/**
* @brief Update the hover preview. Called from the mouse-move path
* while knife is active; redraws the provisional segment
* between the last confirmed point and the cursor.
*/
void updateKnifeHover(OgreWidget* widget, int screenX, int screenY);

/**
* @brief Programmatic cut-point entry: append a knife click at
* parametric position `t` on an existing HE edge, resolved
* against the current mesh. Used by scripted/automated knife
* flows and by unit tests that can't run the widget-based
* hit-test (headless CI, macOS without plugins).
*/
bool addKnifePointOnEdge(int heEdgeIndex, float t);

/**
* @brief Apply the current cut point list as splitEdge operations and
* push one undo command. Clears the session afterwards.
* No-op (returns false) if fewer than 2 points are confirmed.
*/
Q_INVOKABLE bool commitKnife();

/// @brief Abandon the current knife session without mutating the mesh.
Q_INVOKABLE void cancelKnife();

/// @brief Whether a knife session is active. Exposed as a Q_PROPERTY
/// so QML bindings get a real bool.
Q_PROPERTY(bool knifeSessionActiveValue READ knifeSessionActive
NOTIFY knifeSessionChanged)
bool knifeSessionActive() const { return m_knifeSession.active; }

/// @brief Count of confirmed cut points. QML uses this to decide
/// whether Enter would commit or is a no-op.
Q_PROPERTY(int knifePointCountValue READ knifePointCount
NOTIFY knifeSessionChanged)
int knifePointCount() const {
return static_cast<int>(m_knifeSession.points.size());
}
/// @}

/// @name Vertex transform support
/// @{
/// Get the centroid of selected vertices in local mesh space.
Expand Down Expand Up @@ -493,6 +553,9 @@
void validationChanged();
/// Emitted when the bevel profile points vector changes (size or value).
void bevelProfilePointsChanged();
/// Emitted whenever the knife session starts, gains a point, or ends —
/// so QML toolbar state and preview overlay refresh together.
void knifeSessionChanged();

private slots:
void onSelectionChanged();
Expand Down Expand Up @@ -563,6 +626,52 @@
BevelSession m_bevelSession;
std::unique_ptr<class BevelGizmo> m_bevelGizmo;

// Knife session state — populated on beginKnife, mutated by each
// addKnifePoint / updateKnifeHover, consumed on commitKnife.
struct KnifePoint {
enum Kind { OnVertex, OnEdge, OnFace };

Check warning on line 632 in src/EditModeController.h

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this "enum" with "enum class".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2-IMgr85-Qd9mO1E_q&open=AZ2-IMgr85-Qd9mO1E_q&pullRequest=308
Kind kind = OnFace;
// OnVertex: vertexIndex. OnEdge: edgeIndex + edgeT. OnFace:
// triangleIndex + world-space position (no splitEdge needed for
// on-face points — they land inside a face and the commit
// pipeline handles them separately).
int vertexIndex = -1;
int edgeIndex = -1;
float edgeT = 0.5f;
int triangleIndex = -1;
// Local-space position of the point (for preview rendering and
// on-face fallback placement).
Ogre::Vector3 localPosition = Ogre::Vector3::ZERO;
};

struct KnifeSession {
bool active = false;
std::vector<KnifePoint> points; ///< Confirmed points in click order.
bool hoverValid = false; ///< True while cursor hit-test is hitting the mesh.
KnifePoint hover; ///< Preview point under the cursor.
};
KnifeSession m_knifeSession;

/// Hit-test a screen-space point for knife placement. Priority:
/// snap to existing vertex within pixelRadius, else snap to edge
/// within pixelRadius, else ray-cast to a face. Writes the result
/// into `out` and returns true on a successful hit.
bool knifeHitTest(const QPoint& screenPos, OgreWidget* widget,
KnifePoint& out) const;

/// Rebuild the knife preview overlay from the current session
/// (confirmed points + hover), creating it on first use.
void updateKnifePreviewOverlay();

/// Destroy the knife preview overlay at session end.
void destroyKnifePreviewOverlay();

Ogre::ManualObject* m_overlayKnife = nullptr;
// Independent scene node for the knife preview so its entity-mirror
// transform doesn't fight with selection overlays, which expect
// m_overlayNode parked at the origin with local-space geometry.
Ogre::SceneNode* m_overlayKnifeNode = nullptr;

/// Apply a bevel at `width` to `edges` assuming the mesh is at its
/// pre-bevel snapshot state. Updates selection to the new chamfer verts.
/// Internal helper shared by beginBevel / updateBevelWidth.
Expand Down
193 changes: 193 additions & 0 deletions src/EditModeController_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The MIT License
#include "TestHelpers.h"
#include "Manager.h"
#include "SelectionSet.h"
#include "UndoManager.h"
#include "HalfEdgeMesh.h"
#include <Ogre.h>
#include <QSignalSpy>
#include <set>
Expand Down Expand Up @@ -1347,3 +1349,194 @@ TEST_F(EditModeControllerBevelE2ETest, BevelCubeCornerVertexProducesClosedManifo
for (auto& [_, c] : edgeUse) if (c == 1) ++boundaryEdges;
EXPECT_EQ(boundaryEdges, 0u) << "vertex bevel leaves " << boundaryEdges << " boundary edges";
}

// ===========================================================================
// Knife tool — session lifecycle (hit-test-free tests)
//
// The hit-test paths depend on an OgreWidget, which isn't available headless.
// These tests drive the controller API directly by pushing synthetic
// KnifePoint records through the commit pipeline, exercising the bits that
// do run in CI: enter/cancel/commit state transitions, short-circuit on
// zero-cut commits, breadcrumb emission via the active bevel guard, etc.
// ===========================================================================

TEST_F(EditModeControllerBevelE2ETest, KnifeBeginThenCancelRestoresIdleState) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();

EXPECT_FALSE(ctrl->knifeSessionActive());
EXPECT_TRUE(ctrl->beginKnife());
EXPECT_TRUE(ctrl->knifeSessionActive());
EXPECT_EQ(ctrl->knifePointCount(), 0);

ctrl->cancelKnife();
EXPECT_FALSE(ctrl->knifeSessionActive());
EXPECT_EQ(ctrl->knifePointCount(), 0);
}

TEST_F(EditModeControllerBevelE2ETest, KnifeCommitWithoutPointsRejectsAndCleansUp) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ASSERT_TRUE(ctrl->beginKnife());

// Zero confirmed points — commit should refuse and tidy up.
EXPECT_FALSE(ctrl->commitKnife());
EXPECT_FALSE(ctrl->knifeSessionActive());
}

TEST_F(EditModeControllerBevelE2ETest, KnifeBeginCancelsActiveBevelFirst) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ctrl->setSelectionMode(EditModeController::VertexMode);
ctrl->selectVertex(5, false);
ASSERT_TRUE(ctrl->bevelSelection());
ASSERT_TRUE(ctrl->bevelSessionActive());

// Opening the knife should cancel the bevel session first.
ASSERT_TRUE(ctrl->beginKnife());
EXPECT_TRUE(ctrl->knifeSessionActive());
EXPECT_FALSE(ctrl->bevelSessionActive());
}

TEST_F(EditModeControllerBevelE2ETest, BeginBevelCancelsActiveKnifeFirst) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ASSERT_TRUE(ctrl->beginKnife());

// Now the user invokes a vertex bevel — the knife session should get
// cancelled rather than leaving a stale preview behind the gizmo.
ctrl->setSelectionMode(EditModeController::VertexMode);
ctrl->selectVertex(5, false);
ASSERT_TRUE(ctrl->bevelSelection());

EXPECT_TRUE(ctrl->bevelSessionActive());
EXPECT_FALSE(ctrl->knifeSessionActive());
}

TEST_F(EditModeControllerBevelE2ETest, ExitEditModeCancelsKnifeSession) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ASSERT_TRUE(ctrl->beginKnife());
ASSERT_TRUE(ctrl->knifeSessionActive());

ctrl->exitEditMode(false);
EXPECT_FALSE(ctrl->knifeSessionActive());
EXPECT_FALSE(ctrl->isEditModeActive());
}

TEST_F(EditModeControllerBevelE2ETest, KnifeBeginOutsideEditModeFails) {
auto* ctrl = EditModeController::instance();
// Fresh fixture: edit mode is off. beginKnife should be a no-op.
EXPECT_FALSE(ctrl->beginKnife());
EXPECT_FALSE(ctrl->knifeSessionActive());
}

// Resolve an HE edge index for an edge connecting two global vertex
// indices in the controller's current EditableMesh. HE indices are an
// internal-rebuild detail; the knife tests resolve by vertex pair so
// harmless topology-order changes don't flake the assertions.
static int resolveEdgeByVerts(int va, int vb) {
auto* mesh = EditModeController::instance()->currentMesh();
if (!mesh) return -1;
HalfEdgeMesh hm;
if (!hm.buildFromEditableMesh(*mesh)) return -1;
for (size_t e = 0; e < hm.edgeCount(); ++e) {
auto [a, b] = hm.edgeVertices(static_cast<int>(e));
if ((a == va && b == vb) || (a == vb && b == va))
return static_cast<int>(e);
}
return -1;
}

TEST_F(EditModeControllerBevelE2ETest, KnifeCommitWalkAndCutGrowsMeshManifoldly) {
// End-to-end: open a knife session, programmatically push two OnEdge
// clicks on distinct edges of the welded cube, commit. The walk-and-
// cut commit pipeline should splitEdge at both endpoints and (for
// this topology) potentially at interior crossings, leaving the
// mesh manifold with more vertices than before.
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ASSERT_TRUE(ctrl->beginKnife());

// Capture pre-commit vertex count directly from the entity's buffers
// so we're comparing against what Ogre actually holds, not just the
// EditableMesh snapshot.
std::vector<Ogre::Vector3> posBefore;
std::vector<std::array<unsigned, 3>> trisBefore;
extractEntityBuffers(m_entity, posBefore, trisBefore);
const size_t vertsBefore = posBefore.size();

// Welded cube verts 2..5 bound the top face (y=+1). Picking two
// perimeter edges of that face gives a knife cut that's guaranteed
// to land on the same coplanar region — the walk has something to
// do, and the topology is fixed regardless of HE-edge numbering.
const int edgeA = resolveEdgeByVerts(2, 3); // back-top edge
const int edgeB = resolveEdgeByVerts(4, 5); // front-top edge
ASSERT_GE(edgeA, 0);
ASSERT_GE(edgeB, 0);

ASSERT_TRUE(ctrl->addKnifePointOnEdge(edgeA, 0.4f));
ASSERT_TRUE(ctrl->addKnifePointOnEdge(edgeB, 0.6f));
ASSERT_EQ(ctrl->knifePointCount(), 2);

ASSERT_TRUE(ctrl->commitKnife());
EXPECT_FALSE(ctrl->knifeSessionActive());

std::vector<Ogre::Vector3> posAfter;
std::vector<std::array<unsigned, 3>> trisAfter;
extractEntityBuffers(m_entity, posAfter, trisAfter);
EXPECT_GT(posAfter.size(), vertsBefore)
<< "knife commit should have inserted new vertices into the GPU mesh";

// Manifold check: every edge is used by exactly 1 or 2 triangles;
// no edge should be used more than twice (would be non-manifold).
std::map<std::pair<unsigned, unsigned>, int> edgeUse;
for (const auto& t : trisAfter) {
for (int k = 0; k < 3; ++k) {
unsigned u = t[k], v = t[(k + 1) % 3];
auto key = std::make_pair(std::min(u, v), std::max(u, v));
++edgeUse[key];
}
}
for (auto& [key, count] : edgeUse) {
EXPECT_LE(count, 2) << "non-manifold edge in post-knife mesh";
}
}

TEST_F(EditModeControllerBevelE2ETest, KnifeCommitUndoRestoresOriginalVertexCount) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();

std::vector<Ogre::Vector3> posBefore;
std::vector<std::array<unsigned, 3>> trisBefore;
extractEntityBuffers(m_entity, posBefore, trisBefore);
const size_t vertsBefore = posBefore.size();

const int edgeA = resolveEdgeByVerts(2, 3);
const int edgeB = resolveEdgeByVerts(4, 5);
ASSERT_GE(edgeA, 0);
ASSERT_GE(edgeB, 0);

ASSERT_TRUE(ctrl->beginKnife());
ASSERT_TRUE(ctrl->addKnifePointOnEdge(edgeA, 0.5f));
ASSERT_TRUE(ctrl->addKnifePointOnEdge(edgeB, 0.5f));
ASSERT_TRUE(ctrl->commitKnife());

UndoManager::getSingleton()->undo();

std::vector<Ogre::Vector3> posAfterUndo;
std::vector<std::array<unsigned, 3>> trisAfterUndo;
extractEntityBuffers(m_entity, posAfterUndo, trisAfterUndo);
EXPECT_EQ(posAfterUndo.size(), vertsBefore)
<< "undo after knife commit should restore original vertex count";
}

TEST_F(EditModeControllerBevelE2ETest, KnifePointOnInvalidEdgeRefused) {
auto* ctrl = EditModeController::instance();
ctrl->enterEditMode();
ASSERT_TRUE(ctrl->beginKnife());

EXPECT_FALSE(ctrl->addKnifePointOnEdge(-1, 0.5f));
EXPECT_FALSE(ctrl->addKnifePointOnEdge(9999, 0.5f));
EXPECT_EQ(ctrl->knifePointCount(), 0);
}
Loading
Loading