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
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.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}\"")
Expand Down
165 changes: 165 additions & 0 deletions qml/ProfileGraph.qml
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()
}
}
63 changes: 63 additions & 0 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
110 changes: 106 additions & 4 deletions src/EditModeController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment on lines +1745 to +1750

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 | 🟠 Major

Clamp bevel session segments to the UI-supported maximum.

Line 1745 allows arbitrary values through the Q_INVOKABLE path. Even if HalfEdgeMesh clamps internally, m_bevelSession.segments and the profile-point list can still grow without bound. Keep the controller state aligned with the exposed 1..16 range.

🛡️ Proposed fix
-    if (segments < 1) segments = 1;
+    segments = std::clamp(segments, 1, 16);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/EditModeController.cpp` around lines 1745 - 1750, Clamp the incoming
segments value to the UI-supported range (1..16) before using it to resize
profile points or assign to m_bevelSession.segments; e.g., apply a clamp to the
local segments variable (rather than relying on HalfEdgeMesh) so the newPoints
vector is constructed with the bounded count and m_bevelSession.segments is
updated only with the clamped value. This keeps the Q_INVOKABLE path,
m_bevelSession.segments and the profile-point list within the 1..16 range.

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

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 | 🟠 Major

Preserve the current bevel preview when reapply fails.

These paths restore originalSubMeshes before calling applyBevelTopology(). If reapply fails, the session remains active but the mesh is left at the pre-bevel snapshot, so the previous valid preview is lost. Snapshot the current preview before restoring, and roll back to it on failure.

🧯 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
Verify each finding against the current code and only fix it if needed.

In `@src/EditModeController.cpp` around lines 1771 - 1781, Before overwriting the
mesh with m_bevelSession.originalSubMeshes, capture the current preview state
(e.g., copy of m_editableMesh->subMeshes(), selected verts/edges/faces) so if
applyBevelTopology(m_bevelSession.targetEdges, m_bevelSession.width, segments,
newPoints) returns false you can restore that captured preview instead of
leaving the pre-bevel snapshot; on success continue to update
m_bevelSession.segments and m_bevelSession.profilePoints = std::move(newPoints)
and emit bevelProfilePointsChanged() as before. Apply the same pattern to the
other similar blocks that restore originalSubMeshes and then call
applyBevelTopology (the ones that update m_bevelSession.* and 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<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)
Expand All @@ -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();
}

Expand Down Expand Up @@ -1795,6 +1896,7 @@ void EditModeController::cancelBevel()
refreshNormalVisualizer();
updateSelectionOverlay();
validateMesh();
emit bevelProfilePointsChanged();
emit meshDataChanged();
emit editSelectionChanged();
emit editModeChanged();
Expand Down
Loading
Loading