I am trying to have a Pen Tool in my pyqt6 application. I am trying to make it how Inkscape's PenTool works. For now, I am trying to achieve the 'B-Spline' mode.
The issue is that my curve does not stay as it is being drawn by mouse movement. It sort of jumps/moves after placing the 3rd and subsequent point(with degree=2). If you run the below code you will see that after placing the points without moving the mouse it seems ok. But when you move the mouse the path already drawn is updated/displaced/moved or something.
Inkscape's curve doesn't behave like this. It remain consistent between clicks amd mouse movements.
Please, run this code to see what I am trying to explain.
import sys
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QWidget,
QVBoxLayout, QPushButton, QHBoxLayout, QGraphicsLineItem, QGraphicsPathItem, QGraphicsEllipseItem
)
from PyQt6.QtCore import Qt, QObject, pyqtSignal, QPointF
from PyQt6.QtGui import QPainterPath, QBrush, QPen, QColor
import numpy as np
import scipy.interpolate as si
HANDLE_RADIUS = 4.0
class DrawSampler:
"""
Holds a stable dense evaluation grid used while drawing.
- dense_N: number of dense parameter samples (e.g. 512..2048). Higher = smoother while dragging.
- display_every: show every Nth dense sample (use >1 to reduce drawn points for performance).
- domain_max: parameter domain max when grid created (set by init_grid)
"""
def __init__(self, dense_N=1024, display_every=1):
self.dense_N = int(max(128, dense_N))
self.display_every = max(1, int(display_every))
self.u_dense = None
self.domain_max = None
def init_grid(self, domain_max):
# create a fixed dense grid over [0, domain_max]
self.domain_max = float(domain_max)
self.u_dense = np.linspace(0.0, float(domain_max), self.dense_N)
class PenTool(QObject):
class _PenToolNotifier(QObject):
pathFinished = pyqtSignal(QPainterPath, object)
def __init__(self, scene, parent=None):
super().__init__(parent)
self._notifier = self._PenToolNotifier()
self.scene = scene
self.view = None
self._draw_sampler = DrawSampler(dense_N=1024, display_every=1)
# Modes: 'bezier', 'spline', 'spiro', 'polyline', 'paraxial'
self._mode = 'spline'
# Drawing state
self.drawing = False
self.path = QPainterPath()
self.path_item = None
self.handles = []
self.anchor_points = [] # list of dicts: { 'pt': QPointF, 'in': QPointF or None, 'out': QPointF or None }
# Appearance
self.pen = QPen(Qt.GlobalColor.black, 1.5)
self.brush = QBrush(Qt.GlobalColor.transparent)
self.handle_pen = QPen(Qt.GlobalColor.darkGray)
self.handle_brush = QBrush(Qt.GlobalColor.white)
# Interaction bookkeeping
self._mouse_pressed = False
self._dragging = False
self._drag_start_pos = None
self._last_pos = QPointF()
# Preview items
self._preview_poly_item = None
self._preview_ctrl_items = []
# pyspiro wrapper lazy
self._pyspiro_available = None
# ----------------- public API --------------------------------------
def set_mode(self, mode_name: str):
if mode_name not in ('bezier', 'spline', 'spiro', 'polyline', 'paraxial'):
raise ValueError("invalid mode")
self._mode = mode_name
def activate(self, view):
self.view = view
view.setDragMode(QGraphicsView.DragMode.NoDrag)
view.viewport().installEventFilter(self)
view.setCursor(Qt.CursorShape.CrossCursor)
def deactivate(self):
if self.view:
try:
self.view.viewport().removeEventFilter(self)
except Exception as e:
print('error: ', e)
self.view.unsetCursor()
self.view = None
self._clear_temp()
# ----------------- internals ---------------------------------------
def _clear_temp(self):
if self.path_item:
try:
self.scene.removeItem(self.path_item)
except Exception as e:
print('error: ', e)
self.path_item = None
if self._preview_poly_item:
try:
self.scene.removeItem(self._preview_poly_item)
except Exception as e:
print('error: ', e)
self._preview_poly_item = None
for h in self.handles:
try:
self.scene.removeItem(h)
except Exception as e:
print('error: ', e)
self.handles.clear()
for l in self._preview_ctrl_items:
try:
self.scene.removeItem(l)
except Exception as e:
print('error: ', e)
self._preview_ctrl_items.clear()
self.anchor_points.clear()
self.path = QPainterPath()
self.drawing = False
def eventFilter(self, obj, event):
from PyQt6.QtGui import QMouseEvent, QKeyEvent
evtype = event.type()
# Mouse press - left or right
if evtype == QMouseEvent.Type.MouseButtonPress:
if event.button() == Qt.MouseButton.LeftButton:
self._mouse_pressed = True
pos = self.view.mapToScene(event.position().toPoint())
self._on_mouse_press(pos, event.modifiers())
return True
if event.button() == Qt.MouseButton.RightButton:
# finalize on right click
if self.drawing:
self._finish_path()
return True
# Mouse move
if evtype == QMouseEvent.Type.MouseMove:
pos = self.view.mapToScene(event.position().toPoint())
self._on_mouse_move(pos, event.buttons(), event.modifiers())
return True
# Mouse release - right can also finalize on release if preferred
if evtype == QMouseEvent.Type.MouseButtonRelease:
if event.button() == Qt.MouseButton.LeftButton:
pos = self.view.mapToScene(event.position().toPoint())
self._mouse_pressed = False
self._on_mouse_release(pos, event.modifiers())
return True
if event.button() == Qt.MouseButton.RightButton:
# finalizing on release as well (mirrors press behavior)
if self.drawing:
self._finish_path()
return True
# Key press handling
if evtype == QKeyEvent.Type.KeyPress:
key = event.key()
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if self.drawing:
self._finish_path()
return True
if key in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete):
if self.drawing:
self._remove_last_point()
return True
if key == Qt.Key.Key_Escape:
if self.drawing:
self._cancel_path()
return True
return False
# ----------------- input handlers ---------------------------------
def _on_mouse_press(self, scene_pos: QPointF, modifiers):
self._drag_start_pos = QPointF(scene_pos)
self._last_pos = QPointF(scene_pos)
self._dragging = False
if not self.drawing:
# start new path
self.drawing = True
self.path = QPainterPath(scene_pos)
self.anchor_points = [{'pt': QPointF(scene_pos), 'in': None, 'out': None}]
self.path_item = QGraphicsPathItem(self.path)
self.path_item.setPen(self.pen)
self.path_item.setBrush(self.brush)
self.scene.addItem(self.path_item)
self._add_handle(scene_pos)
self._update_preview_visuals()
return
# Already drawing: behavior depends on mode
if self._mode == 'bezier':
self.anchor_points.append({'pt': QPointF(scene_pos), 'in': None, 'out': None})
self._add_handle(scene_pos)
self._rebuild_path()
self._update_preview_visuals()
elif self._mode == 'polyline':
self.anchor_points.append({'pt': QPointF(scene_pos), 'in': None, 'out': None})
self._add_handle(scene_pos)
self._rebuild_path()
self._update_preview_visuals()
elif self._mode == 'paraxial':
prev = self.anchor_points[-1]['pt']
if modifiers & Qt.KeyboardModifier.ShiftModifier:
new_pt = QPointF(scene_pos)
else:
dx = abs(scene_pos.x() - prev.x())
dy = abs(scene_pos.y() - prev.y())
if dx > dy:
new_pt = QPointF(scene_pos.x(), prev.y())
else:
new_pt = QPointF(prev.x(), scene_pos.y())
self.anchor_points.append({'pt': new_pt, 'in': None, 'out': None})
self._add_handle(new_pt)
self._rebuild_path()
self._update_preview_visuals()
elif self._mode in ('spline', 'spiro'):
# add raw anchor; smoothing applied on finish
self.anchor_points.append({'pt': QPointF(scene_pos), 'in': None, 'out': None})
self._add_handle(scene_pos)
self._rebuild_path()
self._update_preview_visuals()
def _on_mouse_move(self, scene_pos: QPointF, buttons, modifiers):
if not self.drawing:
return
if self._mode == 'bezier' and self._mouse_pressed:
delta = scene_pos - self._drag_start_pos
if (delta.manhattanLength() > 3):
self._dragging = True
idx = len(self.anchor_points) - 1
if idx >= 0:
last = self.anchor_points[idx]
last['out'] = QPointF(scene_pos)
if idx - 1 >= 0:
vec = last['out'] - last['pt']
last['in'] = last['pt'] - (vec * 0.5)
self._update_handle_visual(idx)
self._rebuild_path()
self._update_preview_visuals(mouse_pos=scene_pos)
return
# For other modes or when not dragging: show preview line to mouse
self._update_preview_visuals(mouse_pos=scene_pos)
def _on_mouse_release(self, scene_pos, modifiers):
if not self.drawing:
return
if not self._dragging:
# click without drag: check for closing in bezier/polyline modes
if len(self.anchor_points) >= 2:
first = self.anchor_points[0]['pt']
if (scene_pos - first).manhattanLength() < 8.0:
self.anchor_points[-1]['pt'] = QPointF(first)
self._rebuild_path(close=True)
self._finish_path()
return
self._last_pos = QPointF(scene_pos)
self._dragging = False
self._drag_start_pos = None
self._update_preview_visuals()
# ----------------- path building ----------------------------------
# ----------------- finish / cancel / remove ------------------------
def _finish_path(self):
if not self.drawing:
return
# If spline mode, convert anchor_points to smoothed path first
pts = [pt['pt'] for pt in self.anchor_points]
if self._mode == 'spline':
try:
p = self._build_cubic_uniform_bspline(pts,
sampler=self._draw_sampler,
finalize=False)
if p is None:
p = QPainterPath()
except Exception as e:
print('error: ', e)
p = QPainterPath()
self.path = p
if self.path_item:
self.path_item.setPath(self.path)
# finalize item
if self.path_item:
self.path_item.setFlag(QGraphicsPathItem.GraphicsItemFlag.ItemIsSelectable, True)
self.path_item.setFlag(QGraphicsPathItem.GraphicsItemFlag.ItemIsMovable, True)
finished_path = QPainterPath(self.path)
finished_item = self.path_item
self.path_item = None
self.handles.clear()
self.anchor_points.clear()
self.path = QPainterPath()
self.drawing = False
# remove preview visuals
if self._preview_poly_item:
try:
self.scene.removeItem(self._preview_poly_item)
except Exception as e:
print('error: ', e)
self._preview_poly_item = None
for l in self._preview_ctrl_items:
try:
self.scene.removeItem(l)
except Exception as e:
print('error: ', e)
self._preview_ctrl_items.clear()
self._notifier.pathFinished.emit(finished_path, finished_item)
def _rebuild_path(self, close=False):
if not self.anchor_points:
return
mode = self._mode
anchors = self.anchor_points
p = None
if mode == 'spline':
pts = [a['pt'] for a in anchors]
try:
p = self._build_cubic_uniform_bspline(pts, closed=close,
sampler=self._draw_sampler,
finalize=False)
except Exception as e:
print('error: ', e)
p = None
if p is None:
# fallback previews: polyline-like for simple modes, or bezier for handle mode
if mode in ('polyline', 'paraxial', 'spiro', 'spline'):
p = QPainterPath(anchors[0]['pt'])
for a in anchors[1:]:
p.lineTo(a['pt'])
if close:
p.closeSubpath()
else:
p = QPainterPath(anchors[0]['pt'])
prev = anchors[0]
for cur in anchors[1:]:
prev_out = prev.get('out')
cur_in = cur.get('in')
if prev_out and cur_in:
p.cubicTo(prev_out, cur_in, cur['pt'])
elif prev_out and not cur_in:
in_pt = QPointF(cur['pt'] - (prev_out - prev['pt']) * 0.5)
p.cubicTo(prev_out, in_pt, cur['pt'])
else:
p.lineTo(cur['pt'])
prev = cur
if close:
p.closeSubpath()
# only set when changed to reduce unnecessary repaints
if p != self.path:
self.path = p
if self.path_item:
self.path_item.setPath(p)
def _cancel_path(self):
self._clear_temp()
def _remove_last_point(self):
if not self.drawing:
return
if len(self.anchor_points) <= 1:
self._cancel_path()
return
self.anchor_points.pop()
h = self.handles.pop()
self.scene.removeItem(h)
self._rebuild_path()
self._update_preview_visuals()
# ----------------- visuals -----------------------------------------
def _add_handle(self, scene_pos: QPointF):
h = QGraphicsEllipseItem(-HANDLE_RADIUS, -HANDLE_RADIUS, HANDLE_RADIUS * 2, HANDLE_RADIUS * 2)
h.setPen(self.handle_pen)
h.setBrush(self.handle_brush)
h.setZValue(1000)
h.setPos(scene_pos)
self.scene.addItem(h)
self.handles.append(h)
self._ensure_ctrl_lines()
def _update_handle_visual(self, idx):
if 0 <= idx < len(self.handles):
h = self.handles[idx]
pt = self.anchor_points[idx]['pt']
h.setPos(pt)
self._update_preview_visuals()
# ----------------- preview helpers --------------------------------
def _ensure_preview_items(self):
if not self._preview_poly_item:
self._preview_poly_item = QGraphicsPathItem()
pen = QPen(Qt.GlobalColor.darkGray, 1, Qt.PenStyle.DashLine)
pen.setCosmetic(True)
self._preview_poly_item.setPen(pen)
self._preview_poly_item.setZValue(900)
self.scene.addItem(self._preview_poly_item)
def _ensure_ctrl_lines(self):
needed = max(0, len(self.anchor_points) * 2)
while len(self._preview_ctrl_items) < needed:
line = QGraphicsLineItem()
pen = QPen(Qt.GlobalColor.lightGray, 1, Qt.PenStyle.DotLine)
pen.setCosmetic(True)
line.setPen(pen)
line.setZValue(950)
self.scene.addItem(line)
self._preview_ctrl_items.append(line)
# hide extras
for i in range(len(self._preview_ctrl_items)):
if i >= needed:
self._preview_ctrl_items[i].hide()
else:
self._preview_ctrl_items[i].show()
def _update_preview_visuals(self, mouse_pos=None):
self._ensure_preview_items()
# Build control polyline
if not self.anchor_points:
self._preview_poly_item.setPath(QPainterPath())
else:
pp = QPainterPath(self.anchor_points[0]['pt'])
for i in range(1, len(self.anchor_points)):
pp.lineTo(self.anchor_points[i]['pt'])
if mouse_pos:
pp.lineTo(mouse_pos)
self._preview_poly_item.setPath(pp)
# Update control lines for bezier handles
self._ensure_ctrl_lines()
for i, anchor in enumerate(self.anchor_points):
idx_in = i * 2
idx_out = idx_in + 1
in_line = self._preview_ctrl_items[idx_in] if idx_in < len(self._preview_ctrl_items) else None
out_line = self._preview_ctrl_items[idx_out] if idx_out < len(self._preview_ctrl_items) else None
a_pt = anchor['pt']
if anchor.get('in') and in_line:
in_line.setLine(a_pt.x(), a_pt.y(), anchor['in'].x(), anchor['in'].y())
in_line.show()
elif in_line:
in_line.hide()
if anchor.get('out') and out_line:
out_line.setLine(a_pt.x(), a_pt.y(), anchor['out'].x(), anchor['out'].y())
out_line.show()
elif out_line:
out_line.hide()
# Smooth preview
if self._mode == 'bezier':
if not self.anchor_points:
if self.path_item:
self.path_item.setPath(QPainterPath())
return
temp_path = QPainterPath(self.anchor_points[0]['pt'])
for i in range(1, len(self.anchor_points)):
prev = self.anchor_points[i - 1]
cur = self.anchor_points[i]
if prev.get('out') and cur.get('in'):
temp_path.cubicTo(prev['out'], cur['in'], cur['pt'])
elif prev.get('out') and not cur.get('in'):
in_pt = QPointF(cur['pt'] - (prev['out'] - prev['pt']) * 0.5)
temp_path.cubicTo(prev['out'], in_pt, cur['pt'])
else:
temp_path.lineTo(cur['pt'])
if mouse_pos and self.anchor_points:
temp_path.lineTo(mouse_pos)
if self.path_item:
self.path_item.setPath(temp_path)
return
# other modes: fast b-spline approx for preview (use sampler)
pts = [pt['pt'] for pt in self.anchor_points]
if mouse_pos:
print('pts = ', pts)
pts = pts + [mouse_pos]
else:
print('mouse points', pts)
approx = QPainterPath()
if pts:
try:
approx = self._build_cubic_uniform_bspline(
pts,
sampler=self._draw_sampler,
finalize=False
)
if approx is None:
approx = QPainterPath()
except Exception as e:
print('error: ', e)
approx = QPainterPath()
else:
approx = QPainterPath()
if self.path_item:
self.path_item.setPath(approx)
def _build_cubic_uniform_bspline(self, points, closed=False, degree=2,
samples_per_segment=8, sampler: DrawSampler = None,
finalize=False):
if not points:
return QPainterPath()
pts = []
last = None
eps = 1e-9
for p in points:
x, y = (p.x(), p.y()) if hasattr(p, 'x') else tuple(p)
if last is None or (abs(x - last[0]) > eps or abs(y - last[1]) > eps):
pts.append((float(x), float(y)))
last = (x, y)
if not pts:
return QPainterPath()
cv = np.asarray(pts, dtype=float)
orig_count = len(cv)
k = max(1, int(degree))
if orig_count <= k:
path = QPainterPath()
path.moveTo(QPointF(float(cv[0,0]), float(cv[0,1])))
for x, y in cv[1:]:
path.lineTo(QPointF(float(x), float(y)))
if closed and orig_count > 1:
path.closeSubpath()
print('path1 = ', path)
return path
periodic = bool(closed)
count = orig_count
if periodic:
factor, fraction = divmod(count + k + 1, count)
cv = np.concatenate((cv,) * factor + (cv[:fraction],), axis=0)
kv = np.arange(-k, len(cv) + k + 1)
max_param = len(cv) - k
else:
k = int(np.clip(k, 1, count - 1))
kv = np.clip(np.arange(count + k + 1) - k, 0, count - k)
max_param = count - k
try:
spl = si.BSpline(kv, cv, k)
except Exception:
path = QPainterPath()
path.moveTo(QPointF(float(cv[0,0]), float(cv[0,1])))
for x, y in cv[1:]:
path.lineTo(QPointF(float(x), float(y)))
if periodic and count > 1:
path.closeSubpath()
print('path2 = ', path)
return path
# If a sampler is provided and we are not finalizing, use its dense grid for fast preview.
if sampler is not None and not finalize:
# ensure sampler grid matches current param range
if sampler.u_dense is None or sampler.domain_max is None or sampler.domain_max != float(max_param):
sampler.init_grid(max_param)
try:
pts_dense = spl(sampler.u_dense)
except Exception:
path = QPainterPath()
path.moveTo(QPointF(float(cv[0,0]), float(cv[0,1])))
for x, y in cv[1:]:
path.lineTo(QPointF(float(x), float(y)))
if periodic and count > 1:
path.closeSubpath()
print('path3 = ', path)
return path
idx = np.arange(0, len(sampler.u_dense), sampler.display_every, dtype=int)
out = pts_dense[idx]
else:
# final path evaluation with requested samples per segment
S = max(1, int(samples_per_segment))
segments = count if periodic else (count - k)
vals = []
for i in range(int(segments)):
for j in range(S):
vals.append(i + (j / float(S)))
vals.append(float(max_param))
u = np.asarray(vals, dtype=float)
try:
out = spl(u)
except Exception:
path = QPainterPath()
path.moveTo(QPointF(float(cv[0,0]), float(cv[0,1])))
for x, y in cv[1:]:
path.lineTo(QPointF(float(x), float(y)))
if periodic and count > 1:
path.closeSubpath()
print('path4 = ', path)
return path
path = QPainterPath()
path.moveTo(QPointF(float(out[0,0]), float(out[0,1])))
for x, y in out[1:]:
path.lineTo(QPointF(float(x), float(y)))
if periodic:
path.closeSubpath()
return path
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PenTool Demo")
self.resize(900, 700)
central = QWidget()
self.setCentralWidget(central)
vlayout = QVBoxLayout(central)
# toolbar with toggle button
toolbar = QWidget()
th = QHBoxLayout(toolbar)
th.setContentsMargins(0, 0, 0, 0)
self.toggle_btn = QPushButton("Pen Tool")
self.toggle_btn.setCheckable(True)
self.toggle_btn.toggled.connect(self.handlePenToggled)
th.addWidget(self.toggle_btn)
th.addStretch()
vlayout.addWidget(toolbar)
# graphics view
self.view = QGraphicsView()
self.view.viewport().setMouseTracking(True)
self.scene = QGraphicsScene(0, 0, 2000, 2000)
self.view.setScene(self.scene)
# enable antialiasing
self.view.setRenderHints(self.view.renderHints())
vlayout.addWidget(self.view)
# pen tool
self.pen_tool = PenTool(self.scene)
self.pen_tool._notifier.pathFinished.connect(self.handlePathFinished)
def handlePenToggled(self, checked):
if checked:
self.pen_tool.activate(self.view)
else:
self.pen_tool.deactivate()
def handlePathFinished(self, path, meta):
print("Path finished; metadata:", meta)
# Create a visible item for the finished path
# item = QGraphicsPathItem(path)
# pen = QPen(QColor(0, 100, 200), 2)
# pen.setCosmetic(True)
# item.setPen(pen)
# self.scene.addItem(item)
# # call sample user hook (you provided earlier signature)
# print("Path finished; element:", item)
def main():
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()