pyQt - Trying to draw B-Spline curve with mouse
10:54 08 Nov 2025

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()

python pyqt pyqt6