diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c9aabcc8..cd6fc4d18 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.28.1 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/qml/ProfileGraph.qml b/qml/ProfileGraph.qml new file mode 100644 index 000000000..5b5e6a118 --- /dev/null +++ b/qml/ProfileGraph.qml @@ -0,0 +1,165 @@ +import QtQuick 2.15 + +// 2D graph control for the bevel profile. Displays N-1 vertically- +// draggable control points for N segments; endpoints are fixed on the +// midline. Click-and-drag picks the nearest point; double-click resets. +Item { + id: root + + property var values: [0.5] + signal pointChanged(int index, real v) + signal resetRequested() + + property color backgroundColor: "#1a1a1a" + property color borderColor: "#444" + property color midlineColor: "#555" + property color curveColor: "#4a9eff" + property color endpointColor: "#888" + property color handleColor: "#4a9eff" + property color handleBorderColor: "#ffffff" + + implicitWidth: 180 + implicitHeight: 110 + + Rectangle { + anchors.fill: parent + color: root.backgroundColor + border.color: root.borderColor + radius: 3 + } + + readonly property real padX: 10 + readonly property real padY: 10 + readonly property real plotLeft: padX + readonly property real plotRight: width - padX + readonly property real plotTop: padY + readonly property real plotBottom: height - padY + readonly property real plotWidth: plotRight - plotLeft + readonly property real plotHeight: plotBottom - plotTop + readonly property real midY: plotTop + plotHeight * 0.5 + + readonly property int pointCount: (values !== undefined && values !== null + && values.length !== undefined) + ? values.length : 0 + readonly property int segments: pointCount + 1 + + function tToX(t) { return plotLeft + t * plotWidth } + function indexToT(i) { return i / segments } + function valueToY(v) { return plotBottom - v * plotHeight } + function yToValue(y) { + var v = (plotBottom - y) / plotHeight + return Math.max(0.0, Math.min(1.0, v)) + } + + onValuesChanged: curveCanvas.requestPaint() + onWidthChanged: curveCanvas.requestPaint() + onHeightChanged: curveCanvas.requestPaint() + + Canvas { + id: curveCanvas + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + + ctx.strokeStyle = root.midlineColor + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.beginPath() + ctx.moveTo(root.plotLeft, root.midY) + ctx.lineTo(root.plotRight, root.midY) + ctx.stroke() + ctx.setLineDash([]) + + ctx.strokeStyle = root.curveColor + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(root.plotLeft, root.midY) + var n = root.pointCount + for (var i = 0; i < n; ++i) { + var t = root.indexToT(i + 1) + ctx.lineTo(root.tToX(t), root.valueToY(root.values[i])) + } + ctx.lineTo(root.plotRight, root.midY) + ctx.stroke() + } + } + + Rectangle { + width: 6; height: 6; radius: 3 + color: root.endpointColor + x: root.plotLeft - width / 2 + y: root.midY - height / 2 + } + Rectangle { + width: 6; height: 6; radius: 3 + color: root.endpointColor + x: root.plotRight - width / 2 + y: root.midY - height / 2 + } + + Repeater { + model: root.pointCount + delegate: Rectangle { + required property int index + width: 12; height: 12; radius: 6 + color: root.handleColor + border.color: root.handleBorderColor + border.width: 2 + x: root.tToX(root.indexToT(index + 1)) - width / 2 + y: { + var v = 0.5 + if (root.values && index < root.values.length) + v = root.values[index] + return root.valueToY(v) - height / 2 + } + z: 10 + } + } + + function nearestIndex(x) { + var n = root.pointCount + if (n <= 0) return -1 + var best = 0 + var bestDist = Math.abs(root.tToX(root.indexToT(1)) - x) + for (var i = 1; i < n; ++i) { + var d = Math.abs(root.tToX(root.indexToT(i + 1)) - x) + if (d < bestDist) { bestDist = d; best = i } + } + return best + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + cursorShape: Qt.SizeVerCursor + preventStealing: true + + property int activeIndex: -1 + + function applyY(my) { + if (activeIndex < 0) return + var clamped = Math.max(root.plotTop, Math.min(root.plotBottom, my)) + var v = root.yToValue(clamped) + var curr = (root.values !== undefined && activeIndex < root.values.length) + ? root.values[activeIndex] + : 0.5 + if (Math.abs(v - curr) > 1e-4) { + root.pointChanged(activeIndex, v) + } + } + + onPressed: function(mouse) { + activeIndex = root.nearestIndex(mouse.x) + applyY(mouse.y) + } + onPositionChanged: function(mouse) { + if (!pressed) return + applyY(mouse.y) + } + onReleased: { activeIndex = -1 } + onDoubleClicked: root.resetRequested() + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0d96b7237..a40df75d6 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -518,6 +518,69 @@ Rectangle { } } + // Bevel session controls (visible only while a bevel session is + // active — i.e., between Cmd+B and the commit/cancel click). + // Lets the user tweak segment count and profile shape while the + // gizmo is up. + Column { + visible: EditModeController.bevelSessionActiveValue + width: parent.width - 16 + spacing: 4 + + // Segments + Row { + width: parent.width + spacing: 6 + Text { + text: "Segments" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 60 + } + SpinBox { + id: bevelSegmentsSpin + from: 1 + to: 16 + value: EditModeController.bevelSegmentsValue + onValueModified: EditModeController.updateBevelSegments(value) + width: parent.width - 70 + } + } + + // Profile shape — 2D graph control with one handle per + // interior segment. Only shown when segments > 1 (a single + // segment has no interior points to shape). + Column { + visible: EditModeController.bevelSegmentsValue > 1 + width: parent.width + spacing: 4 + + Text { + text: "Profile" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + ProfileGraph { + id: bevelProfileGraph + width: parent.width + height: 100 + values: EditModeController.bevelProfilePointsList + onPointChanged: (idx, v) => EditModeController.updateBevelProfilePoint(idx, v) + onResetRequested: EditModeController.resetBevelProfile() + } + Text { + text: "Drag a dot · double-click to reset" + color: PropertiesPanelController.subtleTextColor !== undefined + ? PropertiesPanelController.subtleTextColor + : "#888" + font.pixelSize: 9 + width: parent.width + wrapMode: Text.Wrap + } + } + } + // Separator Rectangle { width: parent.width - 16; height: 1; color: PropertiesPanelController.borderColor } diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 2a1af4791..9e3162b19 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -1436,7 +1436,8 @@ bool EditModeController::extrudeSelection() } bool EditModeController::applyBevelTopology( - const std::vector>& edges, float width) + const std::vector>& edges, float width, + int segments, const std::vector& profilePoints) { if (!m_editModeActive || !m_editableMesh || !m_editEntity) return false; @@ -1461,7 +1462,8 @@ bool EditModeController::applyBevelTopology( } } - std::vector newHEVertices = heMesh.bevelEdges(edgeIndices, width); + std::vector newHEVertices = + heMesh.bevelEdges(edgeIndices, width, segments, 0.5f, profilePoints); if (newHEVertices.empty()) return false; @@ -1641,6 +1643,7 @@ bool EditModeController::beginBevel() s.active = true; m_bevelSession = std::move(s); + emit bevelProfilePointsChanged(); // Spawn the gizmo. Created lazily against the edit entity's scene manager. if (!m_bevelGizmo && m_editEntity) { @@ -1722,10 +1725,107 @@ void EditModeController::updateBevelWidth(float width) m_selectedEdges = m_bevelSession.origSelectedEdges; m_selectedFaces = m_bevelSession.origSelectedFaces; - if (applyBevelTopology(m_bevelSession.targetEdges, width)) + if (applyBevelTopology(m_bevelSession.targetEdges, width, + m_bevelSession.segments, m_bevelSession.profilePoints)) m_bevelSession.width = width; } +QVariantList EditModeController::bevelProfilePoints() const +{ + QVariantList out; + out.reserve(static_cast(m_bevelSession.profilePoints.size())); + for (float v : m_bevelSession.profilePoints) + out.append(v); + return out; +} + +void EditModeController::updateBevelSegments(int segments) +{ + if (!m_bevelSession.active) return; + if (segments < 1) segments = 1; + if (segments == m_bevelSession.segments) return; + + // Resample existing profile points onto the new segment count so the + // user's curve shape is preserved when the spinner moves up/down. + std::vector newPoints(segments > 1 ? segments - 1 : 0, 0.5f); + if (!m_bevelSession.profilePoints.empty() && segments > 1) { + const int oldN = m_bevelSession.segments; + const int newN = segments; + for (int i = 1; i < newN; ++i) { + const float tNew = static_cast(i) / static_cast(newN); + const float oldPos = tNew * static_cast(oldN); + const int lo = std::max(1, std::min(oldN - 1, + static_cast(std::floor(oldPos)))); + const int hi = std::max(1, std::min(oldN - 1, + static_cast(std::ceil(oldPos)))); + if (lo == hi) { + newPoints[i - 1] = m_bevelSession.profilePoints[lo - 1]; + } else { + const float frac = oldPos - static_cast(lo); + newPoints[i - 1] = m_bevelSession.profilePoints[lo - 1] * (1.0f - frac) + + m_bevelSession.profilePoints[hi - 1] * frac; + } + } + } + + m_editableMesh->subMeshes() = m_bevelSession.originalSubMeshes; + m_selectedVertices = m_bevelSession.origSelectedVertices; + m_selectedEdges = m_bevelSession.origSelectedEdges; + m_selectedFaces = m_bevelSession.origSelectedFaces; + + if (applyBevelTopology(m_bevelSession.targetEdges, m_bevelSession.width, + segments, newPoints)) { + m_bevelSession.segments = segments; + m_bevelSession.profilePoints = std::move(newPoints); + emit bevelProfilePointsChanged(); + } +} + +void EditModeController::updateBevelProfilePoint(int index, float value) +{ + if (!m_bevelSession.active) return; + if (m_bevelSession.segments < 2) return; + if (index < 0 || index >= static_cast(m_bevelSession.profilePoints.size())) + return; + if (value < 0.0f) value = 0.0f; + if (value > 1.0f) value = 1.0f; + if (std::abs(value - m_bevelSession.profilePoints[index]) < 1e-4f) return; + + std::vector newPoints = m_bevelSession.profilePoints; + newPoints[index] = value; + + m_editableMesh->subMeshes() = m_bevelSession.originalSubMeshes; + m_selectedVertices = m_bevelSession.origSelectedVertices; + m_selectedEdges = m_bevelSession.origSelectedEdges; + m_selectedFaces = m_bevelSession.origSelectedFaces; + + if (applyBevelTopology(m_bevelSession.targetEdges, m_bevelSession.width, + m_bevelSession.segments, newPoints)) { + m_bevelSession.profilePoints = std::move(newPoints); + emit bevelProfilePointsChanged(); + } +} + +void EditModeController::resetBevelProfile() +{ + if (!m_bevelSession.active) return; + if (m_bevelSession.segments < 2) return; + + std::vector newPoints(m_bevelSession.segments - 1, 0.5f); + if (newPoints == m_bevelSession.profilePoints) return; + + m_editableMesh->subMeshes() = m_bevelSession.originalSubMeshes; + m_selectedVertices = m_bevelSession.origSelectedVertices; + m_selectedEdges = m_bevelSession.origSelectedEdges; + m_selectedFaces = m_bevelSession.origSelectedFaces; + + if (applyBevelTopology(m_bevelSession.targetEdges, m_bevelSession.width, + m_bevelSession.segments, newPoints)) { + m_bevelSession.profilePoints = std::move(newPoints); + emit bevelProfilePointsChanged(); + } +} + void EditModeController::commitBevel() { if (!m_bevelSession.active) @@ -1743,7 +1843,8 @@ void EditModeController::commitBevel() UndoManager::getSingleton()->push(cmd); m_bevelSession = {}; - if (m_bevelGizmo) m_bevelGizmo->setVisible(false); + if (m_bevelGizmo) { m_bevelGizmo->setVisible(false); } + emit bevelProfilePointsChanged(); emit editModeChanged(); } @@ -1795,6 +1896,7 @@ void EditModeController::cancelBevel() refreshNormalVisualizer(); updateSelectionOverlay(); validateMesh(); + emit bevelProfilePointsChanged(); emit meshDataChanged(); emit editSelectionChanged(); emit editModeChanged(); diff --git a/src/EditModeController.h b/src/EditModeController.h index fb6c12eb1..28bfa9d8e 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -33,6 +33,7 @@ THE SOFTWARE. #include #include #include +#include #include #include #include @@ -242,7 +243,11 @@ class EditModeController : public QObject Q_INVOKABLE void cancelBevel(); /// @brief Whether a bevel session is active (gizmo should be visible). - Q_INVOKABLE bool bevelSessionActive() const { return m_bevelSession.active; } + /// Exposed as a Q_PROPERTY so QML `visible:` bindings get a + /// real bool (not a function reference, which is always truthy). + Q_PROPERTY(bool bevelSessionActiveValue READ bevelSessionActive + NOTIFY bevelProfilePointsChanged) + bool bevelSessionActive() const { return m_bevelSession.active; } /// @brief Gizmo pivot position in local mesh space (chamfer region center). Ogre::Vector3 bevelGizmoOrigin() const { return m_bevelSession.pivot; } @@ -252,6 +257,35 @@ class EditModeController : public QObject /// @brief Currently-applied width (starts at 0.005, grows/shrinks via drag). float bevelGizmoWidth() const { return m_bevelSession.width; } + + /// @brief Currently-applied segment count (1 = single-strip chamfer). + /// Exposed as a Q_PROPERTY so QML bindings (e.g. a segments + /// SpinBox) get a real int, not a function reference. + Q_PROPERTY(int bevelSegmentsValue READ bevelSegments + NOTIFY bevelProfilePointsChanged) + int bevelSegments() const { return m_bevelSession.segments; } + + /// @brief Currently-applied profile points (size = segments-1). + /// Each value in [0, 1]: 0.5 = flat, 1 = max outward bulge, + /// 0 = max inward bulge. Empty when segments == 1. + /// Exposed as a Q_PROPERTY so QML bindings re-evaluate when it + /// changes; updated on every segments/point-value change. + Q_PROPERTY(QVariantList bevelProfilePointsList READ bevelProfilePoints + NOTIFY bevelProfilePointsChanged) + QVariantList bevelProfilePoints() const; + + /// @brief Re-run the active bevel with new segments (>=1). No-op if + /// no session is active or if the value didn't change. + /// Resizes the profile-points vector to segments-1, preserving + /// existing values where possible. + Q_INVOKABLE void updateBevelSegments(int segments); + + /// @brief Re-run the active bevel with a new value at one profile + /// point index. `index` in [0, segments-2], `value` in [0, 1]. + Q_INVOKABLE void updateBevelProfilePoint(int index, float value); + + /// @brief Reset all profile points to 0.5 (flat chamfer). + Q_INVOKABLE void resetBevelProfile(); /// @} /// @name Vertex transform support @@ -456,6 +490,8 @@ class EditModeController : public QObject void normalsModeChanged(); /// Emitted when mesh validation results change. void validationChanged(); + /// Emitted when the bevel profile points vector changes (size or value). + void bevelProfilePointsChanged(); private slots: void onSelectionChanged(); @@ -507,6 +543,10 @@ 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. + 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. + std::vector profilePoints; }; BevelSession m_bevelSession; std::unique_ptr m_bevelGizmo; @@ -515,7 +555,9 @@ private slots: /// pre-bevel snapshot state. Updates selection to the new chamfer verts. /// Internal helper shared by beginBevel / updateBevelWidth. bool applyBevelTopology(const std::vector>& edges, - float width); + float width, + int segments = 1, + const std::vector& profilePoints = {}); /// World-space pivot (entity transform applied to session.pivot). Ogre::Vector3 bevelGizmoWorldOrigin() const; diff --git a/src/EditModeController_test.cpp b/src/EditModeController_test.cpp index 7e962162e..badd60542 100644 --- a/src/EditModeController_test.cpp +++ b/src/EditModeController_test.cpp @@ -821,15 +821,23 @@ class EditModeControllerBevelE2ETest : public ::testing::Test { protected: Ogre::SceneNode* m_node = nullptr; Ogre::Entity* m_entity = nullptr; - int s_counter = 0; + std::string m_meshName; + std::string m_nodeName; void SetUp() override { if (!tryInitOgre()) { GTEST_SKIP() << "Ogre not available"; return; } if (!canLoadMeshFiles()) { GTEST_SKIP() << "Cannot create HW buffers"; return; } createStandardOgreMaterials(); - auto mesh = createInMemoryWeldedCube("BevelE2E_cube"); - m_node = Manager::getSingleton()->addSceneNode("BevelE2E_node"); + // Unique names per test so reruns don't collide with Ogre's + // resource manager registry across fixture instances. + static int counter = 0; + ++counter; + m_meshName = "BevelE2E_cube_" + std::to_string(counter); + m_nodeName = "BevelE2E_node_" + std::to_string(counter); + + auto mesh = createInMemoryWeldedCube(m_meshName); + m_node = Manager::getSingleton()->addSceneNode(QString::fromStdString(m_nodeName)); m_entity = Manager::getSingleton()->createEntity(m_node, mesh); m_entity->setMaterialName("BaseWhite"); SelectionSet::getSingleton()->selectOne(m_node); @@ -841,6 +849,12 @@ class EditModeControllerBevelE2ETest : public ::testing::Test { Manager::getSingleton()->destroySceneNode(m_node); m_node = nullptr; } + if (!m_meshName.empty()) { + auto& mm = Ogre::MeshManager::getSingleton(); + if (mm.getByName(m_meshName)) + mm.remove(m_meshName); + m_meshName.clear(); + } } }; @@ -947,3 +961,206 @@ TEST_F(EditModeControllerBevelE2ETest, BevelCubeTopRightEdgeProducesClosedManifo EXPECT_EQ(invertedTris, 0) << invertedTris << " inverted triangles in GPU buffers"; } +// =========================================================================== +// Per-segment bevel profile points: session state, API, signal emission. +// =========================================================================== + +TEST_F(EditModeControllerBevelE2ETest, BevelSegments1HasEmptyProfilePoints) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + + ASSERT_TRUE(ctrl->bevelSelection()); + EXPECT_EQ(ctrl->bevelSegments(), 1); + auto pts = ctrl->bevelProfilePoints(); + EXPECT_EQ(pts.size(), 0); +} + +TEST_F(EditModeControllerBevelE2ETest, UpdateBevelSegmentsResizesProfilePoints) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(3); + EXPECT_EQ(ctrl->bevelSegments(), 3); + auto pts = ctrl->bevelProfilePoints(); + EXPECT_EQ(pts.size(), 2); + for (const auto& v : pts) EXPECT_NEAR(v.toFloat(), 0.5f, 1e-3f); + + ctrl->updateBevelSegments(5); + EXPECT_EQ(ctrl->bevelSegments(), 5); + EXPECT_EQ(ctrl->bevelProfilePoints().size(), 4); + + ctrl->updateBevelSegments(2); + EXPECT_EQ(ctrl->bevelSegments(), 2); + EXPECT_EQ(ctrl->bevelProfilePoints().size(), 1); +} + +TEST_F(EditModeControllerBevelE2ETest, UpdateBevelProfilePointChangesValue) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(4); + ASSERT_EQ(ctrl->bevelProfilePoints().size(), 3); + + ctrl->updateBevelProfilePoint(1, 0.9f); + auto pts = ctrl->bevelProfilePoints(); + ASSERT_EQ(pts.size(), 3); + EXPECT_NEAR(pts[0].toFloat(), 0.5f, 1e-3f); + EXPECT_NEAR(pts[1].toFloat(), 0.9f, 1e-3f); + EXPECT_NEAR(pts[2].toFloat(), 0.5f, 1e-3f); +} + +TEST_F(EditModeControllerBevelE2ETest, UpdateBevelProfilePointClampsRange) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(3); + ASSERT_EQ(ctrl->bevelProfilePoints().size(), 2); + + ctrl->updateBevelProfilePoint(0, -1.0f); + EXPECT_NEAR(ctrl->bevelProfilePoints()[0].toFloat(), 0.0f, 1e-3f); + ctrl->updateBevelProfilePoint(0, 2.0f); + EXPECT_NEAR(ctrl->bevelProfilePoints()[0].toFloat(), 1.0f, 1e-3f); +} + +TEST_F(EditModeControllerBevelE2ETest, UpdateBevelProfilePointInvalidIndexIsNoOp) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(3); + ASSERT_EQ(ctrl->bevelProfilePoints().size(), 2); + auto before = ctrl->bevelProfilePoints(); + ctrl->updateBevelProfilePoint(-1, 0.9f); + ctrl->updateBevelProfilePoint(5, 0.9f); + auto after = ctrl->bevelProfilePoints(); + ASSERT_EQ(before.size(), after.size()); + for (int i = 0; i < before.size(); ++i) + EXPECT_NEAR(before[i].toFloat(), after[i].toFloat(), 1e-6f); +} + +TEST_F(EditModeControllerBevelE2ETest, ResetBevelProfileFlattens) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(4); + ctrl->updateBevelProfilePoint(0, 0.1f); + ctrl->updateBevelProfilePoint(1, 0.9f); + ctrl->updateBevelProfilePoint(2, 0.2f); + + ctrl->resetBevelProfile(); + auto pts = ctrl->bevelProfilePoints(); + ASSERT_EQ(pts.size(), 3); + for (const auto& v : pts) EXPECT_NEAR(v.toFloat(), 0.5f, 1e-3f); +} + +TEST_F(EditModeControllerBevelE2ETest, BevelProfilePointsChangedSignalFires) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + + int fires = 0; + auto conn = QObject::connect(ctrl, &EditModeController::bevelProfilePointsChanged, + [&]() { ++fires; }); + + ASSERT_TRUE(ctrl->bevelSelection()); + EXPECT_GE(fires, 1) << "signal should fire on session start"; + + fires = 0; + ctrl->updateBevelSegments(3); + EXPECT_GE(fires, 1); + + fires = 0; + ctrl->updateBevelProfilePoint(0, 0.8f); + EXPECT_GE(fires, 1); + + fires = 0; + ctrl->resetBevelProfile(); + EXPECT_GE(fires, 1); + + fires = 0; + ctrl->cancelBevel(); + EXPECT_GE(fires, 1); + + QObject::disconnect(conn); +} + +TEST_F(EditModeControllerBevelE2ETest, UpdateBevelProfilePointWithSegments1NoOp) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + ASSERT_EQ(ctrl->bevelSegments(), 1); + + ctrl->updateBevelProfilePoint(0, 0.9f); + EXPECT_EQ(ctrl->bevelProfilePoints().size(), 0); +} + +TEST_F(EditModeControllerBevelE2ETest, ResetBevelProfileWithSegments1NoOp) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->resetBevelProfile(); + EXPECT_EQ(ctrl->bevelProfilePoints().size(), 0); +} + +TEST_F(EditModeControllerBevelE2ETest, ResizingSegmentsPreservesCurveShape) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + + ctrl->updateBevelSegments(3); + ctrl->updateBevelProfilePoint(0, 0.2f); + ctrl->updateBevelProfilePoint(1, 0.8f); + + ctrl->updateBevelSegments(5); + auto pts = ctrl->bevelProfilePoints(); + ASSERT_EQ(pts.size(), 4); + // After resampling, the first and last resampled points should roughly + // bracket the original range — not all be 0.5. + float minV = 1.0f, maxV = 0.0f; + for (const auto& v : pts) { + float f = v.toFloat(); + if (f < minV) minV = f; + if (f > maxV) maxV = f; + } + EXPECT_LT(minV, 0.5f) << "resample should carry the concave side forward"; + EXPECT_GT(maxV, 0.5f) << "resample should carry the convex side forward"; +} + +TEST_F(EditModeControllerBevelE2ETest, SessionEndClearsProfilePoints) { + auto* ctrl = EditModeController::instance(); + ctrl->enterEditMode(); + ctrl->setSelectionMode(EditModeController::EdgeMode); + ctrl->selectEdge(5, 3, false); + ASSERT_TRUE(ctrl->bevelSelection()); + ctrl->updateBevelSegments(3); + ASSERT_EQ(ctrl->bevelProfilePoints().size(), 2); + + ctrl->cancelBevel(); + EXPECT_FALSE(ctrl->bevelSessionActive()); + EXPECT_EQ(ctrl->bevelProfilePoints().size(), 0); +} + diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index e002e029e..eaa517e92 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -933,11 +933,48 @@ std::vector HalfEdgeMesh::extrudeEdges(const std::vector& edgeIndices) return newVertices; } -std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, float width) +// Matches the UI SpinBox upper bound in PropertiesPanel.qml. Large values +// cost O(segments) allocation per beveled endpoint and can overflow the +// hole-filler's 64-vertex loop cap; clamping here keeps the public API +// safe even if a caller bypasses the UI. +static constexpr int kMaxBevelSegments = 16; + +// The Phase 1-7 bevel topology below has high cognitive complexity that +// predates this PR; splitting it into phase-sized helpers is tracked as +// a separate refactor. This PR only adds the optional profilePoints +// parameter and uses it inside buildSegmentVerts. +std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, // NOSONAR(cpp:S3776) + float width, + int segments, + float profile, + const std::vector& profilePointsIn) { std::vector newVertices; if (edgeIndices.empty() || width <= 0.0f) return newVertices; + segments = std::clamp(segments, 1, kMaxBevelSegments); + profile = std::clamp(profile, 0.0f, 1.0f); + + // Build the per-interior-point profile vector. If the caller supplied + // an explicit one of the right size, clamp and use it directly; other- + // wise synthesize from the scalar profile using a sin envelope so a + // single UI number still produces the classic bulge shape. + std::vector profilePoints; + if (segments > 1) { + profilePoints.resize(segments - 1, 0.5f); + if (profilePointsIn.size() == static_cast(segments - 1)) { + for (size_t i = 0; i < profilePoints.size(); ++i) + profilePoints[i] = std::clamp(profilePointsIn[i], 0.0f, 1.0f); + } else { + const float kPi = 3.14159265358979323846f; + const float amp = profile - 0.5f; + for (int i = 1; i < segments; ++i) { + const float t = static_cast(i) + / static_cast(segments); + profilePoints[i - 1] = 0.5f + amp * std::sin(kPi * t); + } + } + } // Snapshot the per-edge info we need before we start mutating faces. // Each entry captures the two endpoint vertices, the two adjacent faces, @@ -1925,17 +1962,102 @@ std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, f if (fullRingV2) processRingNeighbors(info.v2, ringV2); // ===================================================================== - // Phase 6: chamfer quad between f1's and f2's inner offsets. - // Note: we do NOT record these edges in emittedEdges because the - // cap check in buildCorner is "does a NON-CHAMFER tri cover the - // chamfer-end edge?". The chamfer itself always has that edge. + // Phase 6: chamfer strip between f1's and f2's inner offsets. + // + // For segments=1 (default), this is the original two-triangle quad + // bridging (v1a, v2a) on f1's side to (v1b, v2b) on f2's side. + // + // For segments>1, we subdivide the strip into `segments` quads along + // a profile curve. At each endpoint v ∈ {v1, v2} we compute N-1 + // intermediate offset vertices between vA = inner_on_f1 and + // vB = inner_on_f2, parametrized t = i / segments for i in [1..N-1]. + // Position is lerp(vA.pos, vB.pos, t) plus a per-point bulge along + // the chamfer-plane normal: offset = (profilePoints[i-1] - 0.5) * w. + // - p = 0.5 → flat (no bulge), reproduces the original geometry. + // - p > 0.5 → convex (rounded outward, fillet-like). + // - p < 0.5 → concave (cut inward, groove-like). + // + // The scalar-profile entry point (scalar overload) pre-fills the + // vector with the old sin-envelope so the single-number UX keeps + // working; callers with explicit per-segment values get a linear + // interpretation matching what the user draws in the graph. + // + // The bulge direction "outward" is away from v (the original corner + // we cut off), so positive bulge pushes the strip toward where the + // original sharp edge USED to be. + // + // We do NOT record these edges in emittedEdges because the cap check + // in buildCorner is "does a NON-CHAMFER tri cover the chamfer-end + // edge?". The chamfer itself always has that edge. // ===================================================================== - if (f1WalksAB) { - appendTriangle(v1b, v2b, v2a, info.subMeshIndex); - appendTriangle(v1b, v2a, v1a, info.subMeshIndex); - } else { - appendTriangle(v2b, v1b, v1a, info.subMeshIndex); - appendTriangle(v2b, v1a, v2a, info.subMeshIndex); + // Outward bulge direction: from the chord midpoint toward the + // original corner v. Convex profile bulges in this direction + // (rounded fillet), concave bulges opposite (digs into the solid). + auto computeOutward = [&](const Ogre::Vector3& pA, + const Ogre::Vector3& pB, + const Ogre::Vector3& pV) { + const auto chord = pB - pA; + auto outward = pV - (pA + pB) * 0.5f; + if (const float chordLen2 = chord.squaredLength(); chordLen2 > 1e-12f) + outward -= chord * (outward.dotProduct(chord) / chordLen2); + if (outward.length() > 1e-6f) outward.normalise(); + else outward = Ogre::Vector3::ZERO; + return outward; + }; + + // Append one new vertex cloned from `v` and positioned at `pos`. + // Returns the new vertex index and records it in newVertices. + auto appendChamferVertex = [&](int v, const Ogre::Vector3& pos) { + HEVertex nv = m_vertices[v]; + nv.position = pos; + nv.halfEdge = -1; + const auto idx = static_cast(m_vertices.size()); + m_vertices.push_back(nv); + newVertices.push_back(idx); + return idx; + }; + + // Build one endpoint's chain: innerA → N-1 intermediates → innerB. + // Each intermediate is offset along `outward` by + // (profilePoints[i-1] - 0.5) * w + // linearly. The user's drawn curve translates directly into the + // resulting bulge. + auto buildSegmentVerts = [&](int v, int innerA, int innerB) { + std::vector chain; + chain.reserve(segments + 1); + chain.push_back(innerA); + if (segments > 1) { + const auto pA = m_vertices[innerA].position; + const auto pB = m_vertices[innerB].position; + const auto chord = pB - pA; + const auto outward = computeOutward(pA, pB, m_vertices[v].position); + for (int i = 1; i < segments; ++i) { + const float t = static_cast(i) + / static_cast(segments); + const float pt = profilePoints[i - 1]; + const auto pos = pA + chord * t + outward * ((pt - 0.5f) * w); + chain.push_back(appendChamferVertex(v, pos)); + } + } + chain.push_back(innerB); + return chain; + }; + const std::vector v1Chain = buildSegmentVerts(info.v1, v1a, v1b); + const std::vector v2Chain = buildSegmentVerts(info.v2, v2a, v2b); + // Emit two triangles per strip i: bridges chain[i]->chain[i+1] on each + // endpoint. Original (segments=1) winding is preserved when N=1. + for (int i = 0; i < segments; ++i) { + const int a = v1Chain[i]; // f1-side at v1, step i + const int b = v1Chain[i + 1]; // f2-side at v1, step i (toward f2) + const int c = v2Chain[i + 1]; // f2-side at v2, step i (toward f2) + const int d = v2Chain[i]; // f1-side at v2, step i + if (f1WalksAB) { + appendTriangle(b, c, d, info.subMeshIndex); + appendTriangle(b, d, a, info.subMeshIndex); + } else { + appendTriangle(c, b, a, info.subMeshIndex); + appendTriangle(c, a, d, info.subMeshIndex); + } } // ===================================================================== @@ -2373,7 +2495,14 @@ std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, f for (auto& loop : loopsToProcess) { if (loop.size() < 3) continue; - if (loop.size() > 8) continue; // skip big/non-simple loops + // Hard cap to avoid filling bizarrely large loops (which + // almost certainly indicate a non-simple polygon the + // fan-triangulation would bungle). Scales with segment + // count: a multi-segment bevel can legitimately produce + // loops of ~2*(segments+1) verts around the chamfer strip + // perimeter, so the old hard 8 was too tight for + // segments>=4. Use a generous but bounded ceiling. + if (loop.size() > 64) continue; // Only fill loops that contain at least one bevel-created // offset vertex (in newVertices). Loops made entirely of // pre-existing perimeter vertices are legitimate open @@ -2449,15 +2578,22 @@ std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, f refNormal += n; } } - // Geometry-first winding: the fill's visible face should - // match its neighbors' orientation (outward-facing). If - // refNormal is well-defined, trust it — users care about - // what they SEE, and the surrounding mesh may already be - // non-manifold at this location (otherwise fill wouldn't - // be needed), so perfect topological closure isn't - // achievable. - bool flipWinding = flipScore > noFlipScore; - if (refNormal.length() > 0.1f && loop.size() >= 3) { + // Topology-first winding: prefer the winding that partners + // the most directed boundary edges with existing tris and + // duplicates the fewest. This is robust against curved + // fill surfaces (e.g., concave multi-segment bevels) where + // the geometric face-normal check can flip sign based on + // profile even though the correct topological winding is + // unchanged. + // + // Fall back to the geometric refNormal check only when the + // topology scores are tied (ambiguous), which happens when + // the loop's boundary edges don't meaningfully overlap the + // existing mesh — then visual appearance is the only cue. + bool flipWinding; + if (flipScore != noFlipScore) { + flipWinding = flipScore > noFlipScore; + } else if (refNormal.length() > 0.1f && loop.size() >= 3) { Ogre::Vector3 p0 = m_vertices[loop[0]].position; Ogre::Vector3 p1 = m_vertices[loop[1]].position; Ogre::Vector3 p2 = m_vertices[loop[2]].position; @@ -2465,7 +2601,11 @@ std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, f if (noFlipN.length() > 1e-6f) { noFlipN.normalise(); flipWinding = refNormal.dotProduct(noFlipN) < 0; + } else { + flipWinding = false; } + } else { + flipWinding = false; } (void)noFlipConflicts; (void)flipConflicts; diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index 5165ea7d4..b2a4ca180 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -292,19 +292,39 @@ class HalfEdgeMesh * Each adjacent face is retriangulated so its shared edge moves from * (v1, v2) to either (v1a, v2a) or (v1b, v2b). * - * First-version limitations: + * Limitations: * - Boundary edges and non-manifold edges are skipped. * - Edges that share an endpoint with another selected edge are skipped * (the corner would be pulled in inconsistent directions). - * - Flat profile only, 1 segment. * * @param edgeIndices The edge indices to bevel. * @param width The offset distance, in world units, by which each side * of the chamfer is pulled away from the original edge. - * @return Indices of the newly created vertices (the chamfer corners). - * Empty if the operation was skipped or failed. + * @param segments Number of chamfer strips between the two inner offsets + * (1 = single-strip flat chamfer, the original behavior; + * 2+ subdivides the chamfer into N strips along the + * profile curve). Clamped to [1, 16] — the UI SpinBox + * cap; higher values can overflow the hole-filler's + * 64-vertex loop budget. + * @param profile Profile-curve shape in [0, 1]. 0.5 = flat (linear + * interpolation, identical geometry to the single-segment + * case at any segments value); >0.5 bulges outward + * (convex / fillet-like); <0.5 bulges inward (concave / + * groove-like). Clamped to [0, 1]. + * @param profilePoints Optional per-interior-point profile values (size + * must equal segments - 1, each in [0, 1]). When empty, + * `profile` is used with a sin envelope so a single + * number controls the full curve. When supplied, each + * value is used directly and `profile` is ignored. + * @return Indices of the newly created vertices (the chamfer corners + * and any per-segment intermediate vertices). Empty if the + * operation was skipped or failed. */ - std::vector bevelEdges(const std::vector& edgeIndices, float width); + std::vector bevelEdges(const std::vector& edgeIndices, + float width, + int segments = 1, + float profile = 0.5f, + const std::vector& profilePoints = {}); /// @} diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 0064a3662..c6a070087 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -9,6 +9,8 @@ The MIT License */ #include +#include +#include #include "HalfEdgeMesh.h" #include "EditableMesh.h" #include "TestHelpers.h" @@ -2362,3 +2364,264 @@ TEST(HalfEdgeMeshStandalone, SmoothSurfaceBevelReversedWindingManifold) { } EXPECT_EQ(flipped, 0) << flipped << " tris have inverted winding"; } + +// =========================================================================== +// Tests: bevelEdges segments + profile parameters +// =========================================================================== + +namespace { + void expectCubeBevelSegments(int segments, float profile) { + ASSERT_GE(segments, 1); + auto em = makeCubeMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + // Pick a deterministic perimeter edge (top-right vertical 5↔3). + int edgeIdx = findEdge(he, 5, 3); + ASSERT_GE(edgeIdx, 0); + + auto newVerts = he.bevelEdges({edgeIdx}, 0.05f, segments, profile); + ASSERT_FALSE(newVerts.empty()) + << "bevel returned no new verts (segments=" << segments + << " profile=" << profile << ")"; + EXPECT_TRUE(he.validate()) + << "validate() failed (segments=" << segments + << " profile=" << profile << ")"; + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + const auto stats = statsOf(back); + EXPECT_EQ(stats.boundaryEdges, 0u) + << "boundary edges for segments=" << segments + << " profile=" << profile; + EXPECT_TRUE(isManifold(back)) + << "non-manifold output for segments=" << segments + << " profile=" << profile; + + // Each endpoint contributes (segments - 1) intermediate verts. + const size_t intermediates = 2u * static_cast(segments - 1); + EXPECT_GE(newVerts.size(), 4u + intermediates) + << "newVerts too small for segments=" << segments; + } +} + +TEST(HalfEdgeMeshStandalone, BevelSegments1FlatMatchesBaseline) { + // Default args (segments=1, profile=0.5) must reproduce the original + // single-strip chamfer geometry. + auto em = makeCubeMesh(); + HalfEdgeMesh heA, heB; + ASSERT_TRUE(heA.buildFromEditableMesh(em)); + ASSERT_TRUE(heB.buildFromEditableMesh(em)); + int eA = findEdge(heA, 5, 3); + int eB = findEdge(heB, 5, 3); + ASSERT_EQ(eA, eB); + auto a = heA.bevelEdges({eA}, 0.05f); + auto b = heB.bevelEdges({eB}, 0.05f, 1, 0.5f); + EXPECT_EQ(a.size(), b.size()); + EditableMesh outA, outB; + ASSERT_TRUE(heA.toEditableMesh(outA)); + ASSERT_TRUE(heB.toEditableMesh(outB)); + EXPECT_EQ(outA.subMeshes()[0].vertices.size(), + outB.subMeshes()[0].vertices.size()); + EXPECT_EQ(outA.subMeshes()[0].triangles.size(), + outB.subMeshes()[0].triangles.size()); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments2FlatStillManifold) { + expectCubeBevelSegments(2, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments3FlatStillManifold) { + expectCubeBevelSegments(3, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments4FlatStillManifold) { + expectCubeBevelSegments(4, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments8FlatStillManifold) { + expectCubeBevelSegments(8, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments12FlatStillManifold) { + expectCubeBevelSegments(12, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments16FlatStillManifold) { + expectCubeBevelSegments(16, 0.5f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments4ConvexStillManifold) { + // Convex (>0.5) bulges intermediate verts outward toward where the + // original sharp edge was. + expectCubeBevelSegments(4, 1.0f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments4ConcaveStillManifold) { + // Concave (<0.5) bulges intermediate verts inward into the solid, + // producing a groove-like chamfer. + expectCubeBevelSegments(4, 0.0f); +} + +TEST(HalfEdgeMeshStandalone, BevelSegments8ConcaveStillManifold) { + // High segment count + concave profile is a stress case for the + // post-pass hole filler's loop walker. + expectCubeBevelSegments(8, 0.0f); +} + +namespace { + // Run one bevel with the scalar profile overload and return the min + // distance from any vertex (other than v5 itself) to v5=(1,1,1). If + // the bevel fails or the back-conversion fails, returns NaN so the + // caller can flag it without dereferencing empty buffers. + float bevelDistFromV5Scalar(float profile) { + auto em = makeCubeMesh(); + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(em)) return std::nanf(""); + int edgeIdx = findEdge(he, 5, 3); + if (edgeIdx < 0) return std::nanf(""); + auto newVerts = he.bevelEdges({edgeIdx}, 0.05f, 2, profile); + if (newVerts.empty()) return std::nanf(""); + EditableMesh back; + if (!he.toEditableMesh(back) || back.subMeshes().empty()) + return std::nanf(""); + const auto& sub = back.subMeshes()[0]; + const Ogre::Vector3 v5(1, 1, 1); + float minDist = std::numeric_limits::max(); + for (const auto& vert : sub.vertices) { + float d = vert.position.distance(v5); + if (d > 1e-5f && d < minDist) minDist = d; + } + return minDist; + } +} + +TEST(HalfEdgeMeshStandalone, BevelProfileShiftsIntermediateVertexPosition) { + // For segments=2, the single intermediate vertex per endpoint should + // sit at the chord midpoint when profile=0.5 (flat), closer to the + // original cube corner when profile=1 (convex), and farther from it + // when profile=0 (concave, digs into the solid). + const float dFlat = bevelDistFromV5Scalar(0.5f); + const float dConvex = bevelDistFromV5Scalar(1.0f); + const float dConcave = bevelDistFromV5Scalar(0.0f); + ASSERT_FALSE(std::isnan(dFlat)); + ASSERT_FALSE(std::isnan(dConvex)); + ASSERT_FALSE(std::isnan(dConcave)); + EXPECT_LT(dConvex, dFlat) + << "convex profile should bring the chamfer mid closer to the cut-off corner"; + EXPECT_GT(dConcave, dFlat) + << "concave profile should push the chamfer mid farther from the cut-off corner"; +} + +// =========================================================================== +// Tests: per-segment profile points (vector overload) +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, BevelVectorOverloadAllFlatMatchesScalarFlat) { + auto em = makeCubeMesh(); + HalfEdgeMesh heA, heB; + ASSERT_TRUE(heA.buildFromEditableMesh(em)); + ASSERT_TRUE(heB.buildFromEditableMesh(em)); + int eA = findEdge(heA, 5, 3); + int eB = findEdge(heB, 5, 3); + auto a = heA.bevelEdges({eA}, 0.05f, 4, 0.5f); + auto b = heB.bevelEdges({eB}, 0.05f, 4, 0.5f, std::vector{0.5f, 0.5f, 0.5f}); + ASSERT_FALSE(a.empty()); + ASSERT_FALSE(b.empty()); + EXPECT_EQ(a.size(), b.size()); + EditableMesh outA, outB; + ASSERT_TRUE(heA.toEditableMesh(outA)); + ASSERT_TRUE(heB.toEditableMesh(outB)); + EXPECT_EQ(outA.subMeshes()[0].vertices.size(), + outB.subMeshes()[0].vertices.size()); + EXPECT_EQ(outA.subMeshes()[0].triangles.size(), + outB.subMeshes()[0].triangles.size()); +} + +TEST(HalfEdgeMeshStandalone, BevelVectorOverloadWrongSizeFallsBackToFlat) { + // When size != segments-1, the vector overload should treat every + // point as 0.5 (flat chamfer) rather than crashing or corrupting. + auto em = makeCubeMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + int edgeIdx = findEdge(he, 5, 3); + auto newVerts = he.bevelEdges({edgeIdx}, 0.05f, 4, 0.5f, + std::vector{0.9f}); // wrong size + ASSERT_FALSE(newVerts.empty()); + EXPECT_TRUE(he.validate()); + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + EXPECT_TRUE(isManifold(back)); +} + +TEST(HalfEdgeMeshStandalone, BevelVectorOverloadClampsOutOfRangeValues) { + // Values outside [0, 1] should be clamped, not rejected. Bevel still + // succeeds and produces a manifold mesh. + auto em = makeCubeMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + int edgeIdx = findEdge(he, 5, 3); + auto newVerts = he.bevelEdges({edgeIdx}, 0.05f, 4, 0.5f, + std::vector{-0.3f, 2.0f, 1.5f}); + ASSERT_FALSE(newVerts.empty()); + EXPECT_TRUE(he.validate()); + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + EXPECT_TRUE(isManifold(back)); +} + +TEST(HalfEdgeMeshStandalone, BevelVectorOverloadAsymmetricCurveIsAsymmetric) { + // An asymmetric profile (e.g., one side convex, other side concave) + // should produce vertex positions that reflect the asymmetry — the + // distance from each intermediate to the original cut-off corner v5 + // must differ along the chord. + auto em = makeCubeMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + int edgeIdx = findEdge(he, 5, 3); + auto newVerts = he.bevelEdges({edgeIdx}, 0.1f, 4, 0.5f, + std::vector{0.9f, 0.5f, 0.1f}); + ASSERT_FALSE(newVerts.empty()); + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + const Ogre::Vector3 v5(1, 1, 1); + std::vector dists; + for (int idx : newVerts) { + const auto& p = he.vertex(idx).position; + dists.push_back(p.distance(v5)); + } + float minD = *std::min_element(dists.begin(), dists.end()); + float maxD = *std::max_element(dists.begin(), dists.end()); + EXPECT_GT(maxD - minD, 0.01f) << "asymmetric profile produced uniform distances"; +} + +namespace { + // Return mean distance from v5=(1,1,1) to the newly-created chamfer + // vertices. NaN on bevel failure so the caller can flag it. + float meanDistFromV5Vector(const std::vector& points) { + auto em = makeCubeMesh(); + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(em)) return std::nanf(""); + int edgeIdx = findEdge(he, 5, 3); + if (edgeIdx < 0) return std::nanf(""); + auto newVerts = he.bevelEdges({edgeIdx}, 0.1f, 4, 0.5f, points); + if (newVerts.empty()) return std::nanf(""); + const Ogre::Vector3 v5(1, 1, 1); + float sum = 0.0f; + int count = 0; + for (int idx : newVerts) { + sum += he.vertex(idx).position.distance(v5); + ++count; + } + return count > 0 ? sum / count : std::nanf(""); + } +} + +TEST(HalfEdgeMeshStandalone, BevelVectorOverloadAllConvexMovesPointsToward) { + // profilePoints all near 1.0 should bring every interior vertex + // closer to the original cut-off corner than a flat (0.5) profile. + const float dFlat = meanDistFromV5Vector({0.5f, 0.5f, 0.5f}); + const float dConvex = meanDistFromV5Vector({0.95f, 0.95f, 0.95f}); + ASSERT_FALSE(std::isnan(dFlat)); + ASSERT_FALSE(std::isnan(dConvex)); + EXPECT_LT(dConvex, dFlat) << "all-convex points should pull the strip toward v5"; +} diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 37eaf0be4..d7a3f91cc 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -21,6 +21,7 @@ ../qml/CollapsibleSection.qml ../qml/TransformField.qml ../qml/SceneTreeNode.qml + ../qml/ProfileGraph.qml ../qml/AnimationControlPanel.qml