mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Add basic figure drawing support
This commit is contained in:
parent
caed5c3801
commit
6fc6dd8dce
5 changed files with 308 additions and 2 deletions
|
|
@ -447,4 +447,20 @@ actions = ActionList([
|
|||
text='&Open Settings Folder',
|
||||
callback='on_action_open_settings_dir',
|
||||
),
|
||||
Action(
|
||||
id='draw_mode',
|
||||
text='&Draw',
|
||||
shortcuts=['D'],
|
||||
callback='on_action_draw_mode',
|
||||
),
|
||||
Action(
|
||||
id='set_brush_color',
|
||||
text='Brush &Color...',
|
||||
callback='on_action_set_brush_color',
|
||||
),
|
||||
Action(
|
||||
id='set_brush_size',
|
||||
text='Brush &Size...',
|
||||
callback='on_action_set_brush_size',
|
||||
),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ menu_structure = [
|
|||
'arrange_square',
|
||||
],
|
||||
},
|
||||
{
|
||||
'menu': '&Drawing',
|
||||
'items': [
|
||||
'draw_mode',
|
||||
'set_brush_color',
|
||||
'set_brush_size',
|
||||
],
|
||||
},
|
||||
{
|
||||
'menu': '&Images',
|
||||
'items': [
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ class SQLiteIO:
|
|||
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
|
||||
' items.data, null as data '
|
||||
'FROM items '
|
||||
'WHERE items.type = "text"'))
|
||||
'WHERE items.type IN ("text", "path")'))
|
||||
if self.worker:
|
||||
self.worker.begin_processing.emit(len(rows))
|
||||
|
||||
|
|
|
|||
162
beeref/items.py
162
beeref/items.py
|
|
@ -17,9 +17,11 @@
|
|||
text).
|
||||
"""
|
||||
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
import logging
|
||||
import math
|
||||
import os.path
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
|
@ -731,6 +733,166 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
clipboard.setText(self.toPlainText())
|
||||
|
||||
|
||||
@register_item
|
||||
class BeePathItem(BeeItemMixin, QtWidgets.QGraphicsItem):
|
||||
"""Class for freehand drawing/sketch strokes added by the user."""
|
||||
|
||||
TYPE = 'path'
|
||||
|
||||
def __init__(self, strokes=None, **kwargs):
|
||||
super().__init__()
|
||||
self.save_id = None
|
||||
self.is_image = False
|
||||
self.strokes = strokes or []
|
||||
self.temp_stroke = None
|
||||
self._cached_rect = QtCore.QRectF(0, 0, 1, 1)
|
||||
self._cache_pixmap = None
|
||||
self.init_selectable()
|
||||
logger.debug(f'Initialized {self}')
|
||||
|
||||
@classmethod
|
||||
def create_from_data(cls, **kwargs):
|
||||
data = kwargs.get('data', {})
|
||||
item = cls(strokes=data.get('strokes', []))
|
||||
item._update_bounding_rect()
|
||||
item._invalidate_cache()
|
||||
return item
|
||||
|
||||
def __str__(self):
|
||||
n = len(self.strokes)
|
||||
return f'Path ({n} stroke{"s" if n != 1 else ""})'
|
||||
|
||||
def get_extra_save_data(self):
|
||||
return {'strokes': self.strokes}
|
||||
|
||||
def create_copy(self):
|
||||
item = BeePathItem(strokes=copy.deepcopy(self.strokes))
|
||||
item.setPos(self.pos())
|
||||
item.setZValue(self.zValue())
|
||||
item.setScale(self.scale())
|
||||
item.setRotation(self.rotation())
|
||||
if self.flip() == -1:
|
||||
item.do_flip()
|
||||
item._update_bounding_rect()
|
||||
item._invalidate_cache()
|
||||
return item
|
||||
|
||||
def contains(self, point):
|
||||
return self.boundingRect().contains(point)
|
||||
|
||||
def bounding_rect_unselected(self):
|
||||
return QtCore.QRectF(self._cached_rect)
|
||||
|
||||
def add_stroke(self, stroke):
|
||||
self.prepareGeometryChange()
|
||||
self.strokes.append(stroke)
|
||||
self._update_bounding_rect()
|
||||
self._invalidate_cache()
|
||||
self.update()
|
||||
|
||||
def _invalidate_cache(self):
|
||||
self._cache_pixmap = None
|
||||
|
||||
def _update_bounding_rect(self):
|
||||
if not self.strokes:
|
||||
self._cached_rect = QtCore.QRectF(0, 0, 1, 1)
|
||||
return
|
||||
min_x = float('inf')
|
||||
min_y = float('inf')
|
||||
max_x = float('-inf')
|
||||
max_y = float('-inf')
|
||||
max_r = 0
|
||||
for stroke in self.strokes:
|
||||
base_size = stroke.get('base_size', 10)
|
||||
for pt in stroke.get('points', []):
|
||||
r = base_size * pt.get('pressure', 1.0) / 2
|
||||
if r > max_r:
|
||||
max_r = r
|
||||
x = pt['x']
|
||||
y = pt['y']
|
||||
if x < min_x:
|
||||
min_x = x
|
||||
if x > max_x:
|
||||
max_x = x
|
||||
if y < min_y:
|
||||
min_y = y
|
||||
if y > max_y:
|
||||
max_y = y
|
||||
pad = max_r + 1
|
||||
self._cached_rect = QtCore.QRectF(
|
||||
min_x - pad, min_y - pad,
|
||||
(max_x - min_x) + 2 * pad,
|
||||
(max_y - min_y) + 2 * pad)
|
||||
|
||||
def _paint_stroke(self, painter, stroke):
|
||||
color_data = stroke.get('color', [0, 0, 0, 255])
|
||||
color = QtGui.QColor(*color_data)
|
||||
base_size = stroke.get('base_size', 10)
|
||||
points = stroke.get('points', [])
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QtGui.QBrush(color))
|
||||
|
||||
for i, pt in enumerate(points):
|
||||
pressure = pt.get('pressure', 1.0)
|
||||
radius = base_size * pressure / 2
|
||||
painter.drawEllipse(
|
||||
QtCore.QPointF(pt['x'], pt['y']),
|
||||
radius, radius)
|
||||
if i > 0:
|
||||
prev = points[i - 1]
|
||||
dx = pt['x'] - prev['x']
|
||||
dy = pt['y'] - prev['y']
|
||||
dist = math.hypot(dx, dy)
|
||||
if dist > 1:
|
||||
steps = int(dist)
|
||||
for s in range(1, steps):
|
||||
t = s / dist
|
||||
ix = prev['x'] + dx * t
|
||||
iy = prev['y'] + dy * t
|
||||
prev_p = prev.get('pressure', 1.0)
|
||||
ip = prev_p + (pressure - prev_p) * t
|
||||
ir = base_size * ip / 2
|
||||
painter.drawEllipse(
|
||||
QtCore.QPointF(ix, iy), ir, ir)
|
||||
|
||||
def _ensure_cache(self):
|
||||
if self._cache_pixmap is not None:
|
||||
return
|
||||
if not self.strokes:
|
||||
return
|
||||
rect = self._cached_rect
|
||||
w = max(1, int(math.ceil(rect.width())))
|
||||
h = max(1, int(math.ceil(rect.height())))
|
||||
pixmap = QtGui.QPixmap(w, h)
|
||||
pixmap.fill(QtGui.QColor(0, 0, 0, 0))
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
||||
painter.translate(-rect.x(), -rect.y())
|
||||
for stroke in self.strokes:
|
||||
self._paint_stroke(painter, stroke)
|
||||
painter.end()
|
||||
self._cache_pixmap = pixmap
|
||||
|
||||
def paint(self, painter, option, widget):
|
||||
if self.strokes:
|
||||
self._ensure_cache()
|
||||
if self._cache_pixmap is not None:
|
||||
painter.drawPixmap(
|
||||
QtCore.QPointF(
|
||||
self._cached_rect.x(),
|
||||
self._cached_rect.y()),
|
||||
self._cache_pixmap)
|
||||
|
||||
if self.temp_stroke:
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
||||
self._paint_stroke(painter, self.temp_stroke)
|
||||
|
||||
self.paint_selectable(painter, option, widget)
|
||||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
pass
|
||||
|
||||
|
||||
@register_item
|
||||
class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
||||
"""Class for displaying error messages when an item can't be loaded
|
||||
|
|
|
|||
122
beeref/view.py
122
beeref/view.py
|
|
@ -29,7 +29,7 @@ from beeref import fileio
|
|||
from beeref.fileio.errors import IMG_LOADING_ERROR_MSG
|
||||
from beeref.fileio.export import exporter_registry, ImagesToDirectoryExporter
|
||||
from beeref import widgets
|
||||
from beeref.items import BeePixmapItem, BeeTextItem
|
||||
from beeref.items import BeePixmapItem, BeeTextItem, BeePathItem
|
||||
from beeref.main_controls import MainControlsMixin
|
||||
from beeref.scene import BeeGraphicsScene
|
||||
from beeref.utils import get_file_extension_from_format, qcolor_to_hex
|
||||
|
|
@ -46,6 +46,7 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
PAN_MODE = 1
|
||||
ZOOM_MODE = 2
|
||||
SAMPLE_COLOR_MODE = 3
|
||||
DRAW_MODE = 4
|
||||
|
||||
def __init__(self, app, parent=None):
|
||||
super().__init__(parent)
|
||||
|
|
@ -69,6 +70,11 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
self.filename = None
|
||||
self.previous_transform = None
|
||||
self.active_mode = None
|
||||
self.draw_item = None
|
||||
self.draw_current_stroke = None
|
||||
self.draw_brush_size = 10.0
|
||||
self.draw_brush_color = [0, 0, 0, 255]
|
||||
self._tablet_pressure = 1.0
|
||||
|
||||
self.scene = BeeGraphicsScene(self.undo_stack)
|
||||
self.scene.changed.connect(self.on_scene_changed)
|
||||
|
|
@ -107,6 +113,8 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
def cancel_active_modes(self):
|
||||
self.scene.cancel_active_modes()
|
||||
self.cancel_sample_color_mode()
|
||||
if self.active_mode == self.DRAW_MODE:
|
||||
self.exit_draw_mode(commit=True)
|
||||
self.active_mode = None
|
||||
|
||||
def cancel_sample_color_mode(self):
|
||||
|
|
@ -400,6 +408,54 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
pos,
|
||||
self.scene.sample_color_at(self.mapToScene(pos)))
|
||||
|
||||
def on_action_draw_mode(self):
|
||||
if self.active_mode == self.DRAW_MODE:
|
||||
self.exit_draw_mode(commit=True)
|
||||
else:
|
||||
self.enter_draw_mode()
|
||||
|
||||
def enter_draw_mode(self):
|
||||
self.cancel_active_modes()
|
||||
self.scene.deselect_all_items()
|
||||
self.active_mode = self.DRAW_MODE
|
||||
self.viewport().setCursor(Qt.CursorShape.CrossCursor)
|
||||
logger.debug('Entered draw mode')
|
||||
|
||||
def exit_draw_mode(self, commit=True):
|
||||
logger.debug(f'Exiting draw mode, commit={commit}')
|
||||
if self.draw_item:
|
||||
if self.draw_current_stroke:
|
||||
self.draw_item.add_stroke(self.draw_current_stroke)
|
||||
self.draw_item.temp_stroke = None
|
||||
self.draw_current_stroke = None
|
||||
if commit and self.draw_item.strokes:
|
||||
self.scene.removeItem(self.draw_item)
|
||||
pos = self.draw_item.pos()
|
||||
self.undo_stack.push(
|
||||
commands.InsertItems(
|
||||
self.scene, [self.draw_item], pos))
|
||||
else:
|
||||
self.scene.removeItem(self.draw_item)
|
||||
self.draw_item = None
|
||||
self.active_mode = None
|
||||
self.viewport().unsetCursor()
|
||||
|
||||
def on_action_set_brush_color(self):
|
||||
current = QtGui.QColor(*self.draw_brush_color)
|
||||
color = QtWidgets.QColorDialog.getColor(
|
||||
current, self, 'Select Brush Color',
|
||||
QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel)
|
||||
if color.isValid():
|
||||
self.draw_brush_color = [
|
||||
color.red(), color.green(), color.blue(), color.alpha()]
|
||||
|
||||
def on_action_set_brush_size(self):
|
||||
size, ok = QtWidgets.QInputDialog.getInt(
|
||||
self, 'Brush Size', 'Size (px):',
|
||||
int(self.draw_brush_size), 1, 500)
|
||||
if ok:
|
||||
self.draw_brush_size = float(size)
|
||||
|
||||
def on_items_loaded(self, value):
|
||||
logger.debug('On items loaded: add queued items')
|
||||
self.scene.add_queued_items()
|
||||
|
|
@ -854,7 +910,43 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
event.accept()
|
||||
return
|
||||
|
||||
def tabletEvent(self, event):
|
||||
if self.active_mode == self.DRAW_MODE:
|
||||
self._tablet_pressure = event.pressure()
|
||||
# Don't accept — let Qt synthesize mouse events for drawing
|
||||
event.ignore()
|
||||
else:
|
||||
super().tabletEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self.active_mode == self.DRAW_MODE:
|
||||
if event.button() == Qt.MouseButton.RightButton:
|
||||
self.exit_draw_mode(commit=True)
|
||||
event.accept()
|
||||
return
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
scene_pos = self.mapToScene(event.pos())
|
||||
if not self.draw_item:
|
||||
self.draw_item = BeePathItem()
|
||||
self.draw_item.setPos(scene_pos)
|
||||
self.scene.addItem(self.draw_item)
|
||||
local_pos = self.draw_item.mapFromScene(scene_pos)
|
||||
self.draw_current_stroke = {
|
||||
'color': list(self.draw_brush_color),
|
||||
'base_size': self.draw_brush_size,
|
||||
'points': [{
|
||||
'x': round(local_pos.x(), 1),
|
||||
'y': round(local_pos.y(), 1),
|
||||
'pressure': self._tablet_pressure,
|
||||
}],
|
||||
}
|
||||
self.draw_item.temp_stroke = self.draw_current_stroke
|
||||
self.draw_item.update()
|
||||
event.accept()
|
||||
return
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self.mousePressEventMainControls(event):
|
||||
return
|
||||
|
||||
|
|
@ -900,6 +992,20 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if (self.active_mode == self.DRAW_MODE
|
||||
and self.draw_current_stroke is not None):
|
||||
scene_pos = self.mapToScene(event.pos())
|
||||
local_pos = self.draw_item.mapFromScene(scene_pos)
|
||||
self.draw_current_stroke['points'].append({
|
||||
'x': round(local_pos.x(), 1),
|
||||
'y': round(local_pos.y(), 1),
|
||||
'pressure': self._tablet_pressure,
|
||||
})
|
||||
self.draw_item.temp_stroke = self.draw_current_stroke
|
||||
self.draw_item.update()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self.active_mode == self.PAN_MODE:
|
||||
self.reset_previous_transform()
|
||||
pos = event.position()
|
||||
|
|
@ -931,6 +1037,15 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if (self.active_mode == self.DRAW_MODE
|
||||
and self.draw_current_stroke is not None):
|
||||
self.draw_item.add_stroke(self.draw_current_stroke)
|
||||
self.draw_item.temp_stroke = None
|
||||
self.draw_current_stroke = None
|
||||
self._tablet_pressure = 1.0
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self.active_mode == self.PAN_MODE:
|
||||
logger.trace('End pan')
|
||||
self.viewport().unsetCursor()
|
||||
|
|
@ -953,6 +1068,11 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
def keyPressEvent(self, event):
|
||||
if self.keyPressEventMainControls(event):
|
||||
return
|
||||
if self.active_mode == self.DRAW_MODE:
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self.exit_draw_mode(commit=True)
|
||||
event.accept()
|
||||
return
|
||||
if self.active_mode == self.SAMPLE_COLOR_MODE:
|
||||
self.cancel_sample_color_mode()
|
||||
event.accept()
|
||||
|
|
|
|||
Loading…
Reference in a new issue