-
Notifications
You must be signed in to change notification settings - Fork 1
feat(bevel): add segments and profile (convex fillet) parameters #298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
712324c
3e3be9a
4c84c81
2978d79
3c3f246
6886f42
7ca178d
c3284ae
59621a6
3dc4de1
eb49a6e
16c7e56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1436,7 +1436,8 @@ bool EditModeController::extrudeSelection() | |
| } | ||
|
|
||
| bool EditModeController::applyBevelTopology( | ||
| const std::vector<std::pair<int,int>>& edges, float width) | ||
| const std::vector<std::pair<int,int>>& edges, float width, | ||
| int segments, const std::vector<float>& profilePoints) | ||
| { | ||
| if (!m_editModeActive || !m_editableMesh || !m_editEntity) | ||
| return false; | ||
|
|
@@ -1461,7 +1462,8 @@ bool EditModeController::applyBevelTopology( | |
| } | ||
| } | ||
|
|
||
| std::vector<int> newHEVertices = heMesh.bevelEdges(edgeIndices, width); | ||
| std::vector<int> 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<int>(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<float> 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<float>(i) / static_cast<float>(newN); | ||
| const float oldPos = tNew * static_cast<float>(oldN); | ||
| const int lo = std::max(1, std::min(oldN - 1, | ||
| static_cast<int>(std::floor(oldPos)))); | ||
| const int hi = std::max(1, std::min(oldN - 1, | ||
| static_cast<int>(std::ceil(oldPos)))); | ||
| if (lo == hi) { | ||
| newPoints[i - 1] = m_bevelSession.profilePoints[lo - 1]; | ||
| } else { | ||
| const float frac = oldPos - static_cast<float>(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(); | ||
| } | ||
|
Comment on lines
+1771
to
+1781
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve the current bevel preview when reapply fails. These paths restore 🧯 Proposed pattern+ auto previewSubMeshes = m_editableMesh->subMeshes();
+ auto previewSelectedVertices = m_selectedVertices;
+ auto previewSelectedEdges = m_selectedEdges;
+ auto previewSelectedFaces = m_selectedFaces;
+
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();
+ } else {
+ m_editableMesh->subMeshes() = std::move(previewSubMeshes);
+ m_selectedVertices = std::move(previewSelectedVertices);
+ m_selectedEdges = std::move(previewSelectedEdges);
+ m_selectedFaces = std::move(previewSelectedFaces);
+ updateSelectionOverlay();
}Also applies to: 1797-1806, 1817-1826 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| void EditModeController::updateBevelProfilePoint(int index, float value) | ||
| { | ||
| if (!m_bevelSession.active) return; | ||
| if (m_bevelSession.segments < 2) return; | ||
| if (index < 0 || index >= static_cast<int>(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<float> 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<float> 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(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clamp bevel session segments to the UI-supported maximum.
Line 1745 allows arbitrary values through the Q_INVOKABLE path. Even if
HalfEdgeMeshclamps internally,m_bevelSession.segmentsand the profile-point list can still grow without bound. Keep the controller state aligned with the exposed1..16range.🛡️ Proposed fix
🤖 Prompt for AI Agents