PyQt – Ich versuche, eine B-Spline-Kurve mit der Maus zu zeichnenPython

Python-Programme
Anonymous
 PyQt – Ich versuche, eine B-Spline-Kurve mit der Maus zu zeichnen

Post by Anonymous »

Ich versuche, ein Stiftwerkzeug in meiner pyqt6-Anwendung zu haben. Ich versuche, es so zu gestalten, wie das PenTool von Inkscape funktioniert. Im Moment versuche ich, den „B-Spline“-Modus zu erreichen.
Das Problem ist, dass meine Kurve nicht so bleibt, wie sie durch die Mausbewegung gezeichnet wird. Es springt/bewegt sich, nachdem der dritte und die folgenden Punkte platziert wurden (mit Grad = 2). Wenn Sie den folgenden Code ausführen, werden Sie sehen, dass es nach dem Platzieren der Punkte ohne Bewegen der Maus in Ordnung zu sein scheint. Aber wenn Sie die Maus bewegen, wird der bereits gezeichnete Pfad aktualisiert/verschoben/verschoben oder so.
Die Kurve von Inkscape verhält sich nicht so. Es bleibt zwischen Klicks und Mausbewegungen konsistent.
Bitte führen Sie diesen Code aus, um zu sehen, was ich zu erklären versuche.

Code: Select all

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

Quick Reply

Change Text Case: 
   
  • Similar Topics
    Replies
    Views
    Last post