Created
September 26, 2024 06:35
-
-
Save tori29umai0123/ba543059fc57cc9f79d240aaf6fa1bcd to your computer and use it in GitHub Desktop.
LibreSketch.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import configparser | |
import copy | |
import math | |
import os | |
import sys | |
import numpy as np | |
import matplotlib.pyplot as plt | |
import matplotlib as mpl | |
from scipy.interpolate import PchipInterpolator | |
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas | |
from PySide2.QtCore import (QEvent, QPoint, QPointF, QLineF, QRect, QSize, Qt) | |
from PySide2.QtGui import (QColor, QIcon, QImage, QMouseEvent, QPainter, QPen, QTransform, QCursor, QKeyEvent) | |
from PySide2.QtWidgets import (QAction, QApplication, QFileDialog, QLabel, QLineEdit, QMainWindow, QDialog, | |
QDialogButtonBox, QMessageBox, QMdiArea, QMdiSubWindow, QScrollArea, QSlider, | |
QToolBar, QToolButton, QVBoxLayout, QWidget, QSizePolicy, QPushButton, QMenu, | |
QDesktopWidget) | |
# デフォルトの設定ファイル名 | |
DEFAULT_INI_FILE = 'settings.ini' | |
# アプリケーションの基盤パスを設定 | |
if getattr(sys, 'frozen', False): | |
# 実行ファイルからのパス設定 | |
BASE_PATH = os.path.dirname(sys.executable) | |
INI_DIR = os.path.join(os.path.dirname(BASE_PATH), "LibreSketch", DEFAULT_INI_FILE) | |
ICON_DIR = os.path.join(os.path.dirname(BASE_PATH), "LibreSketch", "icon") | |
else: | |
# スクリプトからのパス設定 | |
BASE_PATH = os.path.dirname(os.path.abspath(__file__)) | |
INI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), DEFAULT_INI_FILE) | |
ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon") | |
def load_icon(filename: str) -> QIcon: | |
""" | |
アイコンファイルを読み込む関数。 | |
:param filename: アイコンファイル名 | |
:return: QIconオブジェクト | |
""" | |
return QIcon(os.path.join(ICON_DIR, filename)) | |
def parse_shortcut(shortcut_str: str) -> tuple: | |
""" | |
ショートカット文字列を解析してキーコードと修飾キーを返す関数。 | |
:param shortcut_str: ショートカットの文字列(例: "Ctrl+S") | |
:return: (キーコード, 修飾キー) のタプル | |
""" | |
modifiers = Qt.NoModifier | |
key_code = None | |
key_mapping = { | |
'ctrl': Qt.ControlModifier, | |
'alt': Qt.AltModifier, | |
'shift': Qt.ShiftModifier, | |
'meta': Qt.MetaModifier, | |
'PLUS': Qt.Key_Plus, | |
'MINUS': Qt.Key_Minus, | |
'DELETE': Qt.Key_Delete, | |
'ESCAPE': Qt.Key_Escape, | |
} | |
for part in map(str.strip, shortcut_str.split('+')): | |
part = part.lower() | |
if part in key_mapping: | |
if isinstance(key_mapping[part], Qt.Modifier): | |
modifiers |= key_mapping[part] | |
else: | |
key_code = key_mapping[part] | |
else: | |
key_code = getattr(Qt, f'Key_{part.upper()}', None) | |
return key_code, modifiers | |
class Point: | |
""" | |
描画点を表すクラス。 | |
""" | |
def __init__(self, x: float, y: float, is_control: bool = False, pressure: float = 1.0): | |
self.x = x | |
self.y = y | |
self.is_control = is_control | |
self.pressure = pressure | |
def to_qpointf(self) -> QPointF: | |
""" | |
QPointFオブジェクトに変換するメソッド。 | |
:return: QPointFオブジェクト | |
""" | |
return QPointF(self.x, self.y) | |
class CatmullRomSpline: | |
""" | |
カトムル・ロムスプラインを扱うクラス。 | |
""" | |
def __init__(self, pts: list): | |
# 入力点のディープコピーを作成し、先頭と末尾に点を追加 | |
self.points = copy.deepcopy(pts) | |
self.points.insert(0, pts[0]) | |
self.points.append(pts[-1]) | |
def __calc_val(self, x0: float, x1: float, v0: float, v1: float, t: float) -> float: | |
""" | |
スプラインの値を計算する補助メソッド。 | |
:param x0: 始点の座標 | |
:param x1: 終点の座標 | |
:param v0: 始点の速度 | |
:param v1: 終点の速度 | |
:param t: 時間パラメータ(0 <= t <= 1) | |
:return: 補間後の座標 | |
""" | |
return (2.0 * x0 - 2.0 * x1 + v0 + v1) * t**3 + \ | |
(-3.0 * x0 + 3.0 * x1 - 2.0 * v0 - v1) * t**2 + \ | |
v0 * t + x0 | |
def __get_value(self, idx: int, t: float) -> tuple: | |
""" | |
指定したインデックスと時間パラメータに基づいてスプラインの値を取得するメソッド。 | |
:param idx: パスのインデックス | |
:param t: 時間パラメータ | |
:return: (x, y) のタプル | |
""" | |
if not 0 <= t <= 1.0: | |
return None | |
p1 = self.points[idx] | |
p2 = self.points[idx + 1] | |
p3 = self.points[idx + 2] | |
p4 = self.points[idx + 3] | |
v0 = (p3.x - p1.x) * 0.5, (p3.y - p1.y) * 0.5 | |
v1 = (p4.x - p2.x) * 0.5, (p4.y - p2.y) * 0.5 | |
return ( | |
self.__calc_val(p2.x, p3.x, v0[0], v1[0], t), | |
self.__calc_val(p2.y, p3.y, v0[1], v1[1], t), | |
) | |
def get_key_points(self) -> list: | |
""" | |
キーポイント(スプラインの主要な点)を取得するメソッド。 | |
:return: キーポイントのリスト | |
""" | |
return self.points[1:-1] | |
def plot(self, div: int): | |
""" | |
スプラインをプロットするジェネレータ。 | |
:param div: 分割数 | |
:yield: 各プロット点の(x, y)タプル | |
""" | |
length = len(self.points) - 3 | |
for i in range(length): | |
for j in range(div): | |
_p = self.__get_value(i, j / div) | |
if _p: | |
yield _p | |
# 最後の点を追加 | |
yield (self.points[-1].x, self.points[-1].y) | |
class LineSegment: | |
""" | |
線分を表すクラス。 | |
""" | |
def __init__(self, points: list): | |
self.points = points # 点のリストを保持 | |
class Path: | |
""" | |
描画パスを表すクラス。 | |
""" | |
def __init__(self, mode: str = 'line'): | |
self.segments = [] # パスに含まれるセグメントのリスト | |
self.selected = False | |
self.selected_point = None | |
self.color = Qt.black | |
self.width = 2 | |
self.mode = mode # 'line' または 'curve' | |
self.is_closed = False # パスが閉じているかどうかのフラグ | |
def add_segment(self, points: list): | |
""" | |
パスに新しいセグメントを追加するメソッド。 | |
:param points: セグメントに含まれる点のリスト | |
""" | |
self.segments.append(LineSegment(points)) | |
class Action: | |
""" | |
ユーザーのアクションを表すクラス。Undo/Redo機能に使用。 | |
""" | |
def __init__(self, action_type: str, path_index: int = None, segment_index: int = None, point_index: int = None, | |
old_point=None, new_point=None, path=None, paths_before=None, paths_after=None): | |
self.action_type = action_type | |
self.path_index = path_index | |
self.segment_index = segment_index | |
self.point_index = point_index | |
self.old_point = old_point | |
self.new_point = new_point | |
self.path = path | |
self.paths_before = paths_before | |
self.paths_after = paths_after | |
class DrawingWidget(QWidget): | |
""" | |
描画を行うウィジェットクラス。 | |
ユーザーの入力に基づいてパスを描画し、管理する。 | |
""" | |
def __init__(self, parent=None, width=1024, height=1024, dpi=350, main_window=None): | |
super().__init__(parent) | |
self.main_window = main_window | |
self.width = width | |
self.height = height | |
self.dpi = dpi | |
# 描画に関連する属性の初期化 | |
self.paths = [] | |
self.adjusting_pressure = False | |
self.current_path = None | |
self.drawing = False | |
self.selected_point = None | |
self.dragging_point = False | |
self.current_color = Qt.black | |
self.current_line_width = 4 | |
self.current_mode = 'curve' | |
self.input_mode = 'pen_tablet' | |
self.point_interval = 20 | |
self.last_point = None | |
self.history = [] | |
self.history_index = -1 | |
self.just_added_point = None | |
self.smoothing_factor = 0.2 | |
self.original_input_mode = None | |
self.setFocusPolicy(Qt.StrongFocus) | |
self.resize(self.width, self.height) | |
self.scale_factor = 1.0 | |
self.base_width = width | |
self.base_height = height | |
self.rotation_angle = 0 | |
self.is_flipped = False | |
self.is_tablet_event = False | |
self.drag_start_path_index = None | |
self.drag_start_segment_index = None | |
self.pressure_curve_x = None | |
self.pressure_curve_y = None | |
self.start_pos = QPointF(0, 0) | |
self.setMinimumSize(self.width, self.height) | |
self.drag_start_segment_points_before = None | |
self.erase_paths_before = None | |
self.eraser_active = False | |
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) | |
def is_point_in_canvas(self, x: float, y: float) -> bool: | |
""" | |
指定された座標がキャンバス内にあるかを判定するメソッド。 | |
:param x: x座標 | |
:param y: y座標 | |
:return: キャンバス内ならTrue、外ならFalse | |
""" | |
return 0 <= x < self.width and 0 <= y < self.height | |
def get_transform(self) -> QTransform: | |
""" | |
現在のスケール、回転、反転を反映した変換行列を取得するメソッド。 | |
:return: QTransformオブジェクト | |
""" | |
transform = QTransform() | |
center = QPointF(self.width / 2, self.height / 2) | |
transform.translate(center.x(), center.y()) | |
transform.rotate(self.rotation_angle) | |
transform.scale(self.scale_factor, self.scale_factor) | |
if self.is_flipped: | |
transform.scale(-1, 1) | |
transform.translate(-center.x(), -center.y()) | |
return transform | |
def sizeHint(self) -> QSize: | |
"""ウィジェットの推奨サイズを返すメソッド。""" | |
return QSize(self.width, self.height) | |
def set_mode(self, mode: str): | |
""" | |
描画モードを設定するメソッド。 | |
:param mode: 'line' または 'curve' | |
""" | |
self.current_mode = mode | |
def set_input_mode(self, mode: str): | |
""" | |
入力モードを設定するメソッド。 | |
:param mode: 'mouse', 'pen_tablet', 'control_points' | |
""" | |
self.input_mode = mode | |
def set_point_interval(self, interval: int): | |
""" | |
ペンタブレットモードでの点間の距離を設定するメソッド。 | |
:param interval: 点間の距離(ピクセル) | |
""" | |
self.point_interval = interval | |
def set_width(self, width: int): | |
""" | |
線の太さを設定するメソッド。 | |
:param width: 線の太さ | |
""" | |
self.current_line_width = width | |
if self.current_path: | |
self.current_path.width = width | |
def set_color(self, color: QColor): | |
""" | |
線の色を設定するメソッド。 | |
:param color: QColorオブジェクト | |
""" | |
self.current_color = color | |
if self.current_path: | |
self.current_path.color = color | |
def set_pressure_curve(self, x_curve, y_curve): | |
""" | |
筆圧カーブを設定するメソッド。 | |
:param x_curve: X軸のカーブデータ | |
:param y_curve: Y軸のカーブデータ | |
""" | |
self.pressure_curve_x = x_curve | |
self.pressure_curve_y = y_curve | |
def apply_pressure_curve(self, raw_pressure): | |
""" | |
筆圧カーブを適用して、実際の筆圧を計算するメソッド。 | |
:param raw_pressure: タブレットからの生の筆圧(0.0〜1.0) | |
:return: カーブに基づいた補正後の筆圧 | |
""" | |
if self.pressure_curve_x is None or self.pressure_curve_y is None: | |
return raw_pressure # カーブが設定されていない場合、生の値を返す | |
# 生の筆圧(raw_pressure)を基に、カーブの値を補間して取得 | |
interpolated_pressure = np.interp(raw_pressure, self.pressure_curve_x, self.pressure_curve_y) | |
return interpolated_pressure | |
def get_current_pen_settings(self): | |
return { | |
'smoothing_factor': self.smoothing_factor, | |
'point_interval': self.point_interval, | |
'pressure_curve_x': self.pressure_curve_x, | |
'pressure_curve_y': self.pressure_curve_y | |
} | |
def set_pen_settings(self, settings): | |
self.smoothing_factor = settings['smoothing_factor'] | |
self.point_interval = settings['point_interval'] | |
self.pressure_curve_x = settings['pressure_curve_x'] | |
self.pressure_curve_y = settings['pressure_curve_y'] | |
def set_line_width(self, width: int): | |
self.current_line_width = width | |
if self.current_path: | |
self.current_path.width = width | |
def get_current_line_width(self) -> int: | |
return self.current_line_width | |
def paintEvent(self, event): | |
""" | |
描画イベントを処理するメソッド。 | |
キャンバス上のすべてのパスを描画する。 | |
""" | |
painter = QPainter(self) | |
painter.setRenderHint(QPainter.Antialiasing) | |
# 状態を保存 | |
painter.save() | |
# キャンバスの中心点を計算 | |
center = QPointF(self.width / 2, self.height / 2) | |
painter.translate(center) | |
# 回転を適用 | |
painter.rotate(self.rotation_angle) | |
# スケールを適用 | |
painter.scale(self.scale_factor, self.scale_factor) | |
# 左右反転を適用 | |
if self.is_flipped: | |
painter.scale(-1, 1) | |
# 原点に戻す | |
painter.translate(-center) | |
# 背景を白で塗りつぶす | |
painter.fillRect(QRect(0, 0, self.width, self.height), Qt.white) | |
# すべてのパスを描画 | |
for path in self.paths: | |
self.draw_path(painter, path) | |
# 現在描画中のパスを描画 | |
if self.current_path: | |
self.draw_path(painter, self.current_path) | |
# 状態を復元 | |
painter.restore() | |
# 消しゴムモードであれば消しゴムカーソルを描画 | |
if self.input_mode == 'eraser': | |
eraser_radius = self.main_window.eraser_size / 2 | |
cursor_pos = self.mapFromGlobal(QCursor.pos()) # カーソル位置を取得 | |
# 半透明の白い円を描画 | |
painter.setBrush(QColor(255, 255, 255, 100)) # 半透明の白 | |
painter.setPen(QPen(Qt.black, 2)) # 外側の黒い線(太さ2ピクセル) | |
painter.drawEllipse(cursor_pos, eraser_radius, eraser_radius) | |
self.update() # 画面を再描画 | |
def zoom(self, factor): | |
""" | |
ズームを行う際にキャンバスの中心を固定してズームするメソッド。 | |
""" | |
old_center = self.rect().center() | |
old_center_in_scene = self.mapToScene(old_center) | |
self.scale_factor *= factor | |
self.update_size() | |
new_center_in_scene = self.mapToScene(old_center) | |
delta = new_center_in_scene - old_center_in_scene | |
# Check if parent is QScrollArea before trying to access scrollbars | |
if isinstance(self.parent(), QScrollArea): | |
self.parent().horizontalScrollBar().setValue(self.parent().horizontalScrollBar().value() + delta.x()) | |
self.parent().verticalScrollBar().setValue(self.parent().verticalScrollBar().value() + delta.y()) | |
self.update() | |
def zoom_in(self): | |
"""ズームインボタンが押されたときに呼ばれるメソッド。""" | |
self.zoom(1.1) # 1.1倍ズーム | |
def zoom_out(self): | |
"""ズームアウトボタンが押されたときに呼ばれるメソッド。""" | |
self.zoom(1 / 1.1) # 1/1.1倍ズーム | |
def wheelEvent(self, event): | |
""" | |
マウスホイールイベントを処理するメソッド。 | |
ホイールの方向に応じてズームインまたはズームアウトを行う。 | |
""" | |
# マウスカーソルの位置を取得 | |
cursor_pos = event.posF() | |
transform = self.get_transform() | |
inverted, ok = transform.inverted() | |
if ok: | |
cursor_pos = inverted.map(cursor_pos) | |
old_scale = self.scale_factor | |
if event.angleDelta().y() > 0: | |
self.zoom_in() | |
else: | |
self.zoom_out() | |
new_scale = self.scale_factor | |
# マウスカーソルを中心にズームするための処理 | |
if new_scale != old_scale: | |
scale_factor_change = new_scale / old_scale | |
# キャンバスの中心がマウスカーソルの位置に追従するように平行移動 | |
offset_x = cursor_pos.x() - self.width / 2 | |
offset_y = cursor_pos.y() - self.height / 2 | |
self.move(self.x() - offset_x * (scale_factor_change - 1), | |
self.y() - offset_y * (scale_factor_change - 1)) | |
event.accept() | |
def rotate_clockwise(self): | |
"""時計回りに回転するメソッド。""" | |
self.rotation_angle += 15 | |
self.update() | |
def rotate_counterclockwise(self): | |
"""反時計回りに回転するメソッド。""" | |
self.rotation_angle -= 15 | |
self.update() | |
def flip_horizontal(self): | |
"""左右反転を行うメソッド。""" | |
self.is_flipped = not self.is_flipped | |
self.update() | |
def mapToScene(self, pos): | |
"""ウィジェット座標をシーン座標に変換するメソッド。""" | |
return QPointF(pos.x() / self.scale_factor, pos.y() / self.scale_factor) | |
def mapFromScene(self, pos): | |
"""シーン座標をウィジェット座標に変換するメソッド。""" | |
return QPoint(int(pos.x() * self.scale_factor), int(pos.y() * self.scale_factor)) | |
def update_size(self): | |
"""拡大率に基づいてウィジェットサイズを更新するメソッド。""" | |
new_width = int(self.width * self.scale_factor) | |
new_height = int(self.height * self.scale_factor) | |
self.resize(new_width, new_height) | |
self.update() | |
def draw_path(self, painter: QPainter, path: Path): | |
""" | |
指定されたパスを描画するメソッド。 | |
:param painter: QPainterオブジェクト | |
:param path: 描画するPathオブジェクト | |
""" | |
painter.setBrush(Qt.NoBrush) | |
for segment in path.segments: | |
if len(segment.points) < 1: | |
continue | |
if path.mode == 'curve': | |
spline = CatmullRomSpline(segment.points) | |
spline_points = list(spline.plot(div=100)) | |
previous_point = None | |
for i, (x, y) in enumerate(spline_points): | |
current_point = QPointF(x, y) | |
if self.is_point_in_canvas(current_point.x(), current_point.y()): | |
if previous_point and self.is_point_in_canvas(previous_point.x(), previous_point.y()): | |
# 線の太さを圧力に基づいて調整 | |
t = i / (len(spline_points) - 1) | |
idx = int(t * (len(segment.points) - 1)) | |
P_current = segment.points[idx] | |
P_next = segment.points[idx + 1] if idx + 1 < len(segment.points) else P_current | |
# 線の太さを計算 | |
total_distance = math.hypot(P_next.x - P_current.x, P_next.y - P_current.y) | |
if total_distance == 0: | |
influence = P_current.pressure | |
else: | |
distance_to_current = math.hypot(P_current.x - current_point.x(), | |
P_current.y - current_point.y()) | |
distance_to_next = math.hypot(P_next.x - current_point.x(), P_next.y - current_point.y()) | |
influence = (P_current.pressure * distance_to_next + P_next.pressure * distance_to_current) / total_distance | |
pen_width = max(0.1, influence * path.width) | |
pen = QPen(path.color, pen_width) | |
pen.setCapStyle(Qt.RoundCap) | |
painter.setPen(pen) | |
painter.drawLine(previous_point, current_point) | |
previous_point = current_point | |
else: | |
previous_point = None | |
elif path.mode == 'line': | |
previous_point = None | |
for point in segment.points: | |
current_point = point.to_qpointf() | |
if self.is_point_in_canvas(current_point.x(), current_point.y()): | |
if previous_point and self.is_point_in_canvas(previous_point.x(), previous_point.y()): | |
# 線の太さを圧力の平均で調整 | |
previous_pressure = segment.points[segment.points.index(point) - 1].pressure | |
current_pressure = point.pressure | |
average_pressure = (previous_pressure + current_pressure) / 2 | |
pen_width = max(0.1, average_pressure * path.width) | |
pen = QPen(path.color, pen_width) | |
pen.setCapStyle(Qt.RoundCap) | |
painter.setPen(pen) | |
painter.drawLine(previous_point, current_point) | |
previous_point = current_point | |
else: | |
previous_point = None | |
# 制御点の描画(保存モード以外の場合のみ) | |
if self.input_mode != 'saving': | |
if self.input_mode == 'mouse': | |
if len(segment.points) >= 1 and not path.is_closed: | |
last_point = segment.points[-1].to_qpointf() | |
if self.is_point_in_canvas(last_point.x(), last_point.y()): | |
painter.setBrush(Qt.white) | |
painter.setPen(Qt.black) | |
painter.drawEllipse(last_point, 5, 5) | |
elif self.input_mode == 'control_points': | |
control_point_pen = QPen(Qt.red) | |
painter.setPen(control_point_pen) | |
painter.setBrush(QColor(Qt.red)) | |
for point in segment.points: | |
if self.is_point_in_canvas(point.x, point.y): | |
painter.drawEllipse(point.to_qpointf(), 5, 5) | |
if self.adjusting_pressure and point == self.selected_point: | |
painter.drawText(point.to_qpointf().x() + 10, point.to_qpointf().y(), | |
f"Pressure: {point.pressure:.2f}") | |
def calculate_total_distance(self, points: list) -> float: | |
""" | |
与えられたポイント列の合計距離を計算するメソッド。 | |
:param points: Pointオブジェクトのリスト | |
:return: 合計距離 | |
""" | |
total_distance = 0 | |
for i in range(1, len(points)): | |
p1 = points[i - 1] | |
p2 = points[i] | |
total_distance += math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) | |
return total_distance | |
def is_shortcut_pressed(self, event: QKeyEvent, action_name: str) -> bool: | |
""" | |
ショートカットキーが押されたかを判定するメソッド。 | |
:param event: キーイベント | |
:param action_name: アクション名 | |
:return: ショートカットが押されたらTrue | |
""" | |
shortcut_str = self.main_window.shortcuts.get(action_name) | |
if shortcut_str: | |
key_code, modifiers = parse_shortcut(shortcut_str) | |
# 修飾キーのみの場合 | |
if key_code is None and modifiers: | |
return event.modifiers() == modifiers | |
# 通常のキー+修飾キーの組み合わせ | |
return event.key() == key_code and event.modifiers() == modifiers | |
return False | |
def parse_shortcut(shortcut_str: str) -> tuple: | |
""" | |
ショートカット文字列を解析してキーコードと修飾キーを返す関数。 | |
:param shortcut_str: ショートカットの文字列(例: "Ctrl+S") | |
:return: (キーコード, 修飾キー) のタプル | |
""" | |
modifiers = Qt.NoModifier | |
key_code = None | |
key_mapping = { | |
'ctrl': Qt.ControlModifier, | |
'alt': Qt.AltModifier, | |
'shift': Qt.ShiftModifier, | |
'meta': Qt.MetaModifier, | |
} | |
for part in map(str.strip, shortcut_str.split('+')): | |
part = part.lower() | |
if part in key_mapping: | |
modifiers |= key_mapping[part] | |
else: | |
key_code = getattr(Qt, f'Key_{part.upper()}', None) | |
return key_code, modifiers | |
def is_shortcut_released(self, event: QKeyEvent, action_name: str) -> bool: | |
""" | |
ショートカットキーが離されたか判定するメソッド。 | |
:param event: キーイベント | |
:param action_name: アクション名 | |
:return: ショートカットが離れたらTrue | |
""" | |
shortcut_str = self.main_window.shortcuts.get(action_name) | |
if shortcut_str: | |
key_code, modifiers = parse_shortcut(shortcut_str) | |
if key_code and event.key() == key_code and event.modifiers() == Qt.NoModifier: | |
return True | |
return False | |
def keyPressEvent(self, event): | |
# ショートカットを優先して処理 | |
if self.is_shortcut_pressed(event, 'undo'): | |
if self.main_window: | |
self.main_window.undo_active_canvas() | |
elif self.is_shortcut_pressed(event, 'redo'): | |
if self.main_window: | |
self.main_window.redo_active_canvas() | |
elif self.is_shortcut_pressed(event, 'change_to_control_points_mode'): | |
if self.main_window: | |
self.main_window.change_input_mode('control_points') | |
elif self.is_shortcut_pressed(event, 'change_to_mouse_mode'): | |
if self.main_window: | |
self.main_window.change_input_mode('mouse') | |
elif self.is_shortcut_pressed(event, 'change_to_pen_tablet_mode'): | |
if self.main_window: | |
self.main_window.change_input_mode('pen_tablet') | |
elif self.is_shortcut_pressed(event, 'change_to_eraser_mode'): | |
if self.input_mode != 'eraser': | |
self.original_input_mode = self.input_mode | |
if self.main_window: | |
self.main_window.change_input_mode('eraser') | |
elif self.is_shortcut_pressed(event, 'clear_canvas'): | |
self.clear_canvas() | |
self.update() | |
elif self.is_shortcut_pressed(event, 'flip_horizontal'): | |
self.flip_horizontal() | |
self.update() | |
elif self.is_shortcut_pressed(event, 'rotate_clockwise'): | |
self.rotate_clockwise() | |
self.update() | |
elif self.is_shortcut_pressed(event, 'rotate_counterclockwise'): | |
self.rotate_counterclockwise() | |
self.update() | |
# 修飾キーが単体で押された場合の処理 | |
elif event.key() in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt]: | |
if self.input_mode != 'control_points': | |
self.original_input_mode = self.input_mode | |
if self.main_window: | |
self.main_window.change_input_mode('control_points', temporary=True) | |
else: | |
super().keyPressEvent(event) | |
def keyReleaseEvent(self, event): | |
""" | |
キーリリースイベントを処理するメソッド。 | |
修飾キーのリリースに応じて入力モードを元に戻す。 | |
""" | |
if self.is_shortcut_released(event, 'change_to_eraser_mode'): | |
if self.original_input_mode: | |
if self.main_window: | |
self.main_window.change_input_mode(self.original_input_mode) | |
self.original_input_mode = None | |
elif event.key() in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt]: | |
# Ctrl、Shift、Alt いずれかのキーが離された場合 | |
if self.original_input_mode: | |
if self.main_window: | |
self.main_window.change_input_mode(self.original_input_mode) | |
self.original_input_mode = None | |
else: | |
super().keyReleaseEvent(event) | |
def end_drawing(self): | |
""" | |
描画の終了を行うメソッド。 | |
現在のパスを閉じて保存し、履歴に追加する。 | |
""" | |
if self.current_path: | |
self.current_path.is_closed = True | |
# 操作履歴に追加(パスを閉じたアクション) | |
path_index = self.paths.index(self.current_path) | |
action = Action('end_path', path_index, 0, 0, new_point=copy.deepcopy(self.current_path)) | |
self.save_action(action) | |
# current_pathをリセット | |
self.current_path = None | |
self.drawing = False | |
self.last_point = None | |
# キャンバスを更新 | |
self.update() | |
def select_control_point(self, pos: QPointF): | |
""" | |
指定された位置に最も近い制御点を選択するメソッド。 | |
:param pos: 選択位置 | |
""" | |
nearest_point = None | |
nearest_distance = float('inf') | |
for path in self.paths: | |
for segment in path.segments: | |
for point in segment.points: | |
distance = math.hypot(point.x - pos.x(), point.y - pos.y()) | |
if distance < nearest_distance and distance < 10: | |
nearest_distance = distance | |
nearest_point = point | |
self.selected_point = nearest_point | |
def tabletEvent(self, event): | |
""" | |
タブレットイベントを処理するメソッド。 | |
ペンタブレットからの入力を適切に処理する。 | |
""" | |
modifiers = event.modifiers() | |
current_pos = event.posF() | |
transform = self.get_transform() | |
inverted, ok = transform.inverted() | |
if ok: | |
current_pos = inverted.map(current_pos) | |
# 時間を記録 | |
current_time = event.timestamp() # タブレットイベントのタイムスタンプ | |
if self.input_mode == 'control_points': | |
if not (modifiers & Qt.ControlModifier or modifiers & Qt.ShiftModifier): | |
if event.type() == QEvent.TabletPress: | |
self.mousePressEvent(QMouseEvent(QEvent.MouseButtonPress, current_pos.toPoint(), Qt.LeftButton, | |
Qt.LeftButton, Qt.NoModifier)) | |
elif event.type() == QEvent.TabletMove: | |
self.mouseMoveEvent(QMouseEvent(QEvent.MouseMove, current_pos.toPoint(), Qt.LeftButton, | |
Qt.LeftButton, Qt.NoModifier)) | |
elif event.type() == QEvent.TabletRelease: | |
self.mouseReleaseEvent(QMouseEvent(QEvent.MouseButtonRelease, current_pos.toPoint(), Qt.LeftButton, | |
Qt.LeftButton, Qt.NoModifier)) | |
else: | |
event.ignore() | |
if self.input_mode == 'pen_tablet': | |
if event.type() == QEvent.TabletPress: | |
self.is_tablet_event = True | |
raw_pressure = event.pressure() | |
pressure = self.apply_pressure_curve(raw_pressure) | |
self.start_pen_drawing(current_pos, pressure) | |
self.last_point = current_pos | |
self.last_time = current_time | |
elif event.type() == QEvent.TabletMove: | |
if self.drawing and self.current_path: | |
last_segment = self.current_path.segments[-1] | |
if len(last_segment.points) > 0: | |
last_point = last_segment.points[-1] | |
distance = math.hypot(last_point.x - current_pos.x(), last_point.y - current_pos.y()) | |
raw_pressure = event.pressure() | |
pressure = self.apply_pressure_curve(raw_pressure) | |
# 時間間隔を計算(秒単位) | |
time_interval = (current_time - self.last_time) / 1000.0 | |
# ズーム倍率を考慮した点の間隔の計算 | |
base_interval = self.point_interval / (pressure * 5) | |
speed_factor = distance / (time_interval + 1e-6) # ゼロ除算を防ぐ | |
adjusted_interval = base_interval * (1 + speed_factor / 100) * (1 / self.scale_factor) | |
# 最小間隔を設定(例: 1ピクセル) | |
min_interval = 1 / self.scale_factor | |
# 距離または時間間隔が閾値を超えた場合にポイントを追加 | |
if distance >= adjusted_interval > 0.1: # 100ms | |
self.add_control_point(current_pos, pressure) | |
self.last_point = current_pos | |
self.last_time = current_time | |
elif event.type() == QEvent.TabletRelease: | |
raw_pressure = event.pressure() | |
pressure = self.apply_pressure_curve(raw_pressure) | |
# 終点に圧力を適用したコントロールポイントを追加 | |
self.add_control_point(current_pos, pressure) | |
self.is_tablet_event = False | |
self.end_pen_drawing() | |
if self.input_mode == 'mouse': | |
if event.button() == QEvent.TabletPress: | |
self.start_pos = current_pos | |
if not self.current_path: | |
self.start_drawing(current_pos) | |
else: | |
if not self.current_path.segments[-1].points or self.current_path.segments[-1].points[-1].to_qpointf() != current_pos: | |
self.add_control_point(current_pos) | |
# 消しゴムモードの処理を追加 | |
if self.input_mode == 'eraser': | |
if event.type() == QEvent.TabletPress: | |
# アクション保存 (ドラッグ開始前の状態) | |
self.erase_paths_before = copy.deepcopy(self.paths) | |
elif event.type() == QEvent.TabletMove: | |
self.erase(current_pos) | |
elif event.type() == QEvent.TabletRelease: | |
# 消去操作を完了 | |
self.erase(current_pos) # 最後の消去操作を実行 | |
# 消しゴム操作後のパスを保存 | |
paths_after = copy.deepcopy(self.paths) | |
# アクションを保存 | |
action = Action( | |
action_type='erase', | |
paths_before=self.erase_paths_before, | |
paths_after=paths_after | |
) | |
self.save_action(action) | |
# 一時保存したパスをリセット | |
self.erase_paths_before = None | |
event.accept() | |
def mousePressEvent(self, event: QMouseEvent): | |
""" | |
マウス押下イベントを処理するメソッド。 | |
現在の入力モードに応じて動作を切り替える。 | |
""" | |
# タブレットイベントが発生している場合は、マウスイベントを無視 | |
if self.is_tablet_event: | |
event.ignore() | |
return | |
pos = QPointF(event.pos()) | |
transform = self.get_transform() | |
inverted, ok = transform.inverted() | |
if ok: | |
pos = inverted.map(pos) | |
if self.input_mode == 'mouse': | |
if event.button() == Qt.LeftButton: | |
self.start_pos = pos | |
if not self.current_path: | |
self.start_drawing(pos) | |
else: | |
if not self.current_path.segments[-1].points or self.current_path.segments[-1].points[-1].to_qpointf() != pos: | |
self.add_control_point(pos) | |
elif self.input_mode == 'pen_tablet': | |
self.start_pos = pos | |
if not self.current_path: | |
self.start_drawing(pos) | |
else: | |
if not self.current_path.segments[-1].points or self.current_path.segments[-1].points[-1].to_qpointf() != pos: | |
self.add_control_point(pos) | |
elif self.input_mode == 'control_points': | |
if event.modifiers() in [Qt.ControlModifier, Qt.ShiftModifier]: | |
self.select_control_point(pos) | |
if not self.selected_point: | |
new_point = self.add_control_point_on_path(pos) | |
if new_point: | |
self.selected_point = new_point | |
self.dragging_point = bool(self.selected_point) | |
if self.dragging_point: | |
# ドラッグ開始時にセグメント全体のポイントを保存 | |
segment = self.get_segment_containing_point(self.selected_point) | |
if segment: | |
self.drag_start_segment_points_before = copy.deepcopy(segment.points) | |
# パスとセグメントのインデックスを保存 | |
for path_idx, path in enumerate(self.paths): | |
if segment in path.segments: | |
self.drag_start_path_index = path_idx | |
self.drag_start_segment_index = path.segments.index(segment) | |
break | |
self.start_pos = pos | |
elif event.modifiers() == Qt.AltModifier: | |
self.remove_control_point(pos) | |
else: | |
self.select_control_point(pos) | |
self.dragging_point = bool(self.selected_point) | |
if self.dragging_point: | |
# ドラッグ開始時にセグメント全体のポイントを保存 | |
segment = self.get_segment_containing_point(self.selected_point) | |
if segment: | |
self.drag_start_segment_points_before = copy.deepcopy(segment.points) | |
# パスとセグメントのインデックスを保存 | |
for path_idx, path in enumerate(self.paths): | |
if segment in path.segments: | |
self.drag_start_path_index = path_idx | |
self.drag_start_segment_index = path.segments.index(segment) | |
break | |
self.start_pos = pos | |
elif self.input_mode == 'eraser': | |
if event.button() == Qt.LeftButton: | |
# アクション保存 (ドラッグ開始前の状態) | |
self.erase_paths_before = copy.deepcopy(self.paths) | |
self.update() | |
def mouseMoveEvent(self, event: QMouseEvent): | |
""" | |
マウス移動イベントを処理するメソッド。 | |
現在の入力モードに応じて動作を切り替える。 | |
""" | |
# タブレットイベントが発生している場合は、マウスイベントを無視 | |
if self.is_tablet_event: | |
event.ignore() | |
return | |
current_pos = event.pos() | |
transform = self.get_transform() | |
inverted, ok = transform.inverted() | |
if ok: | |
current_pos = inverted.map(current_pos) | |
if self.input_mode == 'pen_tablet': | |
if self.drawing and self.current_path: | |
last_segment = self.current_path.segments[-1] | |
if len(last_segment.points) > 0: | |
last_point = last_segment.points[-1] | |
distance = math.hypot(last_point.x - current_pos.x(), last_point.y - current_pos.y()) | |
if distance >= self.point_interval: | |
interpolated_point = self.interpolate_point(last_point, Point(current_pos.x(), current_pos.y()), current_pos) | |
self.add_control_point(QPointF(interpolated_point.x, interpolated_point.y)) | |
elif self.input_mode == 'control_points' and self.dragging_point and self.selected_point: | |
if not (event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier | Qt.AltModifier)): | |
# 圧力調整モード | |
old_pressure = self.selected_point.pressure | |
drag_distance = current_pos.x() - self.start_pos.x() | |
new_pressure = old_pressure + drag_distance * 0.01 | |
new_pressure = max(0.1, min(1.0, new_pressure)) | |
self.selected_point.pressure = new_pressure | |
self.adjusting_pressure = True | |
else: | |
self.adjusting_pressure = False | |
if (self.dragging_point and self.selected_point) or self.just_added_point: | |
if event.modifiers() & Qt.ShiftModifier: | |
self.move_control_point_with_anchors(current_pos) | |
elif event.modifiers() & Qt.ControlModifier: | |
(self.selected_point or self.just_added_point).x = current_pos.x() | |
(self.selected_point or self.just_added_point).y = current_pos.y() | |
elif self.input_mode == 'eraser' and event.buttons() & Qt.LeftButton: | |
self.erase(current_pos) # マウス移動中に消去を実行 | |
self.update() | |
def mouseReleaseEvent(self, event: QMouseEvent): | |
""" | |
マウスボタンリリースイベントを処理するメソッド。 | |
ドラッグの終了やアクションの履歴保存を行う。 | |
""" | |
pos = QPointF(event.pos()) # マウス位置を取得 | |
transform = self.get_transform() | |
inverted, ok = transform.inverted() | |
if ok: | |
pos = inverted.map(pos) # 変換行列を適用 | |
if self.input_mode == 'mouse': | |
self.dragging_point = False | |
self.just_added_point = None | |
elif self.input_mode == 'pen_tablet': | |
self.end_pen_drawing() | |
elif self.input_mode == 'control_points': | |
if self.dragging_point and self.drag_start_segment_points_before: | |
# 移動後のセグメントを取得する | |
if self.drag_start_path_index is not None and self.drag_start_segment_index is not None: | |
segment = self.paths[self.drag_start_path_index].segments[self.drag_start_segment_index] | |
old_points = self.drag_start_segment_points_before | |
new_points = [copy.deepcopy(p) for p in segment.points] | |
# 移動アクションを保存 | |
self.save_move_action(old_points, new_points) | |
# ドラッグ開始時のポイントをリセット | |
self.drag_start_segment_points_before = None | |
self.drag_start_path_index = None | |
self.drag_start_segment_index = None | |
self.dragging_point = False | |
self.just_added_point = None | |
self.adjusting_pressure = False | |
elif event.button() == Qt.LeftButton: | |
# 消しゴムモードでのマウスリリースイベント | |
if self.input_mode == 'eraser': | |
# 消去操作を完了 | |
self.erase(pos) # 最後の消去操作を実行 | |
# 消しゴム操作後のパスを保存 | |
paths_after = copy.deepcopy(self.paths) | |
# アクションを保存 | |
action = Action( | |
action_type='erase', | |
paths_before=self.erase_paths_before, | |
paths_after=paths_after | |
) | |
self.save_action(action) | |
# 一時保存したパスをリセット | |
self.erase_paths_before = None | |
self.update() | |
def end_pen_drawing(self): | |
if self.current_path: | |
# パスを確定させる | |
self.current_path.is_closed = True | |
# パス全体を一つのアクションとして保存 | |
action = Action('pen_draw_path', path=self.current_path) | |
self.save_action(action) | |
# current_pathをリセット | |
self.current_path = None | |
self.drawing = False | |
self.last_point = None | |
def get_segment_containing_point(self, point: Point) -> tuple: | |
""" | |
指定されたポイントが所属するセグメントを取得するメソッド。 | |
:param point: 検索するPointオブジェクト | |
:return: 所属するLineSegmentオブジェクトとそのインデックス、存在しない場合は(None, None) | |
""" | |
for path in self.paths: | |
for segment in path.segments: | |
if point in segment.points: | |
return segment | |
return None | |
def mouseDoubleClickEvent(self, event: QMouseEvent): | |
""" | |
マウスダブルクリックイベントを処理するメソッド。 | |
ダブルクリックで現在のパスを閉じる。 | |
""" | |
if event.button() == Qt.LeftButton and self.current_path: | |
self.current_path.is_closed = True # ダブルクリックで線を終了したことを記録 | |
self.end_drawing() | |
def start_pen_drawing(self, pos: QPointF, pressure: float = 1.0): | |
""" | |
ペンタブ描画の開始を行うメソッド。 | |
新しいPathオブジェクトを作成し、履歴に追加する。 | |
:param pos: 開始位置 | |
""" | |
self.current_path = Path(mode=self.current_mode) | |
self.current_path.color = self.current_color | |
self.current_path.width = self.current_line_width | |
start = [Point(pos.x(), pos.y(), pressure=pressure)] # 圧力情報を使用 | |
self.current_path.add_segment(start) | |
self.paths.append(self.current_path) | |
path_index = len(self.paths) - 1 | |
self.drawing = True | |
action = Action('start_path', path_index, 0, 0, new_point=copy.deepcopy(self.current_path)) | |
self.save_action(action) | |
self.update() | |
def start_drawing(self, pos: QPointF): | |
""" | |
マウス描画の開始を行うメソッド。 | |
新しいPathオブジェクトを作成し、履歴に追加する。 | |
:param pos: 開始位置 | |
""" | |
self.current_path = Path(mode=self.current_mode) | |
self.current_path.color = self.current_color | |
self.current_path.width = self.current_line_width # 太さをここで設定 | |
start = [Point(pos.x(), pos.y())] | |
self.current_path.add_segment(start) | |
# 現在のパスをself.pathsに追加 | |
self.paths.append(self.current_path) | |
path_index = len(self.paths) - 1 | |
self.drawing = True | |
action = Action('start_path', path_index, 0, 0, new_point=copy.deepcopy(self.current_path)) | |
self.save_action(action) | |
self.update() | |
def add_control_point(self, pos: QPointF, pressure: float = 1.0): | |
""" | |
新しい制御点を現在のパスに追加するメソッド。 | |
入力モードに応じてアクションを履歴に保存する。 | |
:param pos: 追加する位置 | |
:param pressure: ペンの圧力 | |
""" | |
if self.current_path and self.current_path.segments: | |
path_index = self.paths.index(self.current_path) # 正しいpath_indexを取得 | |
segment_index = len(self.current_path.segments) - 1 | |
last_segment = self.current_path.segments[segment_index] | |
new_point = Point(pos.x(), pos.y(), pressure=pressure) | |
point_index = len(last_segment.points) | |
last_segment.points.append(new_point) | |
# マウス入力モードの場合は制御点追加ごとにアンドゥ履歴を保存 | |
if self.input_mode == 'mouse': | |
action = Action('add', path_index, segment_index, point_index, new_point=new_point) | |
self.save_action(action) | |
self.update() | |
else: | |
self.start_drawing(pos) | |
def add_control_point_on_path(self, pos: QPointF) -> Point: | |
""" | |
パス上に新しい制御点を追加するメソッド。 | |
:param pos: 追加する位置 | |
:return: 追加したPointオブジェクト、追加できなければNone | |
""" | |
for path_index, path in enumerate(self.paths): | |
for segment_index, segment in enumerate(path.segments): | |
for i in range(len(segment.points) - 1): | |
p1 = segment.points[i] | |
p2 = segment.points[i + 1] | |
if self.point_on_line(pos, p1, p2): | |
new_point = Point(pos.x(), pos.y()) | |
segment.points.insert(i + 1, new_point) | |
self.just_added_point = new_point | |
action = Action('add', path_index, segment_index, i + 1, new_point=new_point) | |
self.save_action(action) | |
self.update() | |
return new_point | |
return None | |
def point_on_line(self, pos: QPointF, p1: Point, p2: Point, tolerance: float = 5.0) -> bool: | |
""" | |
点が線分上にあるかどうかを判定するメソッド。 | |
:param pos: 判定する点 | |
:param p1: 線分の始点 | |
:param p2: 線分の終点 | |
:param tolerance: 許容誤差 | |
:return: 線分上にあればTrue | |
""" | |
d1 = math.hypot(pos.x() - p1.x, pos.y() - p1.y) | |
d2 = math.hypot(pos.x() - p2.x, pos.y() - p2.y) | |
line_length = math.hypot(p2.x - p1.x, p2.y - p1.y) | |
return abs(d1 + d2 - line_length) < tolerance | |
def save_move_action(self, old_points: list, new_points: list): | |
""" | |
複数の制御点を移動する際に、その移動アクションをまとめて保存するメソッド。 | |
:param old_points: 移動前の全ての点のコピーのリスト | |
:param new_points: 移動後の全ての点のコピーのリスト | |
""" | |
if self.drag_start_path_index is not None and self.drag_start_segment_index is not None: | |
action = Action( | |
'move', | |
self.drag_start_path_index, | |
self.drag_start_segment_index, | |
0, # 点のインデックスは不要な場合もあるので適宜調整 | |
old_point=copy.deepcopy(old_points), | |
new_point=copy.deepcopy(new_points) | |
) | |
self.save_action(action) | |
# 移動対象のインデックスをリセット | |
self.drag_start_path_index = None | |
self.drag_start_segment_index = None | |
def remove_control_point(self, pos: QPointF) -> bool: | |
""" | |
指定された位置に最も近い制御点を削除するメソッド。 | |
:param pos: 削除対象の位置 | |
:return: 削除に成功したらTrue | |
""" | |
nearest_point = None | |
nearest_distance = float('inf') | |
removed_path_index = -1 | |
removed_segment_index = -1 | |
removed_point_index = -1 | |
for path_index, path in enumerate(self.paths): | |
for segment_index, segment in enumerate(path.segments): | |
for point_index, point in enumerate(segment.points): | |
distance = math.hypot(point.x - pos.x(), point.y - pos.y()) | |
if distance < nearest_distance and distance < 10: | |
nearest_distance = distance | |
nearest_point = point | |
removed_path_index = path_index | |
removed_segment_index = segment_index | |
removed_point_index = point_index | |
if nearest_point: | |
removed_point = self.paths[removed_path_index].segments[removed_segment_index].points.pop( | |
removed_point_index) | |
action = Action('remove', removed_path_index, removed_segment_index, removed_point_index, | |
old_point=removed_point) | |
self.save_action(action) | |
# セグメント内の点が1つ未満になった場合、セグメントを削除 | |
if len(self.paths[removed_path_index].segments[removed_segment_index].points) < 2: | |
self.paths[removed_path_index].segments.pop(removed_segment_index) | |
# パス内にセグメントが存在しない場合、パスを削除 | |
if not self.paths[removed_path_index].segments: | |
self.paths.pop(removed_path_index) | |
self.update() | |
return True | |
return False | |
def move_control_point_with_anchors(self, new_pos: QPointF): | |
""" | |
選択された制御点を中心に、始点と終点までの距離に比例して他の制御点を移動させるメソッド。 | |
:param new_pos: 新しい位置 | |
""" | |
for path in self.paths: | |
for segment in path.segments: | |
if self.selected_point in segment.points: | |
# 移動前の全ての制御点をコピー | |
old_points = [copy.deepcopy(p) for p in segment.points] | |
index = segment.points.index(self.selected_point) | |
dx = new_pos.x() - self.selected_point.x | |
dy = new_pos.y() - self.selected_point.y | |
# 選択された点を移動 | |
self.selected_point.x = new_pos.x() | |
self.selected_point.y = new_pos.y() | |
start_point = segment.points[0] | |
end_point = segment.points[-1] | |
total_distance = math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) | |
for i, point in enumerate(segment.points): | |
if i == 0 or i == len(segment.points) - 1: | |
continue | |
if point is not self.selected_point: | |
distance_from_start = math.hypot(point.x - start_point.x, point.y - start_point.y) | |
ratio = distance_from_start / total_distance | |
distance_from_selected = math.hypot(point.x - self.selected_point.x, point.y - self.selected_point.y) | |
selected_ratio = distance_from_selected / total_distance | |
influence = math.exp(-selected_ratio * 1.5) | |
point.x += dx * influence | |
point.y += dy * influence | |
break # 一度移動したら他のパスは不要 | |
# アクションの保存は呼び出し元で行う | |
def clear_canvas(self): | |
""" | |
キャンバスをクリアするメソッド。 | |
現在のパスと履歴をリセットする。 | |
""" | |
action = Action('clear', 0, 0, 0, old_point=copy.deepcopy(self.paths)) | |
self.save_action(action) | |
self.paths.clear() | |
self.current_path = None | |
self.drawing = False | |
self.update() | |
def erase(self, pos: QPointF): | |
""" | |
消しゴム操作で、指定された半径内のセグメントを消去するメソッド。 | |
""" | |
radius = self.main_window.eraser_size / 2 | |
erase_center = pos | |
new_paths = [] | |
for path in self.paths: | |
new_segments = [] | |
for segment in path.segments: | |
# 円の外周に制御点を追加 | |
points_with_intersections = self.add_control_point_near_outer_edge(segment, erase_center, radius) | |
current_segment = [] | |
for point in points_with_intersections: | |
distance = math.hypot(point.x - erase_center.x(), point.y - erase_center.y()) | |
is_inside = distance < radius - 1e-6 # 境界上の点を外側として扱う | |
if not is_inside: | |
if not current_segment or not self.points_are_close(current_segment[-1], point): | |
current_segment.append(point) | |
else: | |
if current_segment: | |
if len(current_segment) >= 2: | |
new_segments.append(LineSegment(current_segment)) | |
current_segment = [] | |
if current_segment: | |
if len(current_segment) >= 2: | |
new_segments.append(LineSegment(current_segment)) | |
if new_segments: | |
path.segments = new_segments | |
new_paths.append(path) | |
self.paths = new_paths | |
self.update() | |
def add_control_point_near_outer_edge(self, segment, erase_center: QPointF, radius: float): | |
""" | |
セグメントのポイントに対して、円の外周に制御点を追加するメソッド。 | |
円とセグメントの交点に基づいて、新しい制御点を生成します。 | |
""" | |
new_points = [] | |
for i in range(len(segment.points) - 1): | |
p1 = segment.points[i] | |
p2 = segment.points[i + 1] | |
line = QLineF(p1.to_qpointf(), p2.to_qpointf()) | |
# 線分と円の交点を計算 | |
intersects, intersection_points = self.check_line_circle_intersection(line, erase_center, radius) | |
# 最初の点または既存の点と十分に離れている場合のみ追加 | |
if segment.points and not self.point_exists_nearby(segment.points[-1], new_points): | |
new_points.append(p1) | |
# 交点が存在すれば制御点を追加 | |
if intersects: | |
# 交点を線上の順序でソート | |
intersection_points.sort(key=lambda ip: math.hypot(ip.x() - p1.x, ip.y() - p1.y)) | |
for ip in intersection_points: | |
new_point = Point(ip.x(), ip.y()) | |
new_points.append(new_point) | |
# 最後の点を追加(既存の点と十分に離れている場合のみ) | |
if segment.points and not self.point_exists_nearby(segment.points[-1], new_points): | |
new_points.append(segment.points[-1]) | |
return new_points | |
def point_exists_nearby(self, point: Point, points: list, tolerance: float = 1e-6) -> bool: | |
for existing_point in points: | |
if math.isclose(existing_point.x, point.x, abs_tol=tolerance) and \ | |
math.isclose(existing_point.y, point.y, abs_tol=tolerance): | |
return True | |
return False | |
def points_are_close(self, p1, p2, tolerance=1.0): | |
return math.hypot(p1.x - p2.x, p1.y - p2.y) < tolerance | |
def check_line_circle_intersection(self, line: QLineF, center: QPointF, radius: float): | |
p1 = line.p1() | |
p2 = line.p2() | |
d = p2 - p1 | |
f = p1 - center | |
a = d.x() * d.x() + d.y() * d.y() | |
b = 2 * (f.x() * d.x() + f.y() * d.y()) | |
c = f.x() * f.x() + f.y() * f.y() - radius * radius | |
# 線分が点になっている場合(始点と終点が同じ) | |
if abs(a) < 1e-6: | |
distance_to_center = math.sqrt(c) | |
if abs(distance_to_center - radius) < 1e-6: | |
return True, [p1] | |
return False, [] | |
discriminant = b * b - 4 * a * c | |
if discriminant < 0: | |
return False, [] | |
else: | |
discriminant = math.sqrt(discriminant) | |
t1 = (-b - discriminant) / (2 * a) | |
t2 = (-b + discriminant) / (2 * a) | |
intersection_points = [] | |
if 0 <= t1 <= 1: | |
intersection_point1 = p1 + t1 * d | |
intersection_points.append(intersection_point1) | |
if 0 <= t2 <= 1: | |
intersection_point2 = p1 + t2 * d | |
intersection_points.append(intersection_point2) | |
if intersection_points: | |
return True, intersection_points | |
else: | |
return False, [] | |
def end_erase(self): | |
# 現在のパスを削除後に保存する | |
removed_paths = [copy.deepcopy(path) for path in self.paths] | |
# 削除されたパスのインデックスを取得してアクションを保存 | |
path_indexes = [] | |
for idx, path in enumerate(self.paths): | |
if not path.segments: # 空のパス(削除されたパス)は保存しない | |
path_indexes.append(idx) | |
# アクションを保存 | |
action = Action(action_type='erase', path_index=path_indexes, paths=removed_paths) | |
self.save_action(action) | |
self.update() # キャンバスを更新 | |
def save_action(self, action: Action): | |
""" | |
ユーザーのアクションを履歴に保存するメソッド。 | |
:param action: 保存するActionオブジェクト | |
""" | |
self.history = self.history[:self.history_index + 1] | |
self.history.append(action) | |
self.history_index += 1 | |
def undo(self): | |
""" | |
Undo操作を実行するメソッド。 | |
履歴から最後のアクションを逆に適用する。 | |
""" | |
if self.history_index >= 0: | |
action = self.history[self.history_index] | |
self.apply_action(action, reverse=True) | |
self.history_index -= 1 | |
self.update() | |
def redo(self): | |
""" | |
Redo操作を実行するメソッド。 | |
履歴から次のアクションを適用する。 | |
""" | |
if self.history_index < len(self.history) - 1: | |
self.history_index += 1 | |
action = self.history[self.history_index] | |
self.apply_action(action, reverse=False) | |
self.update() | |
def apply_action(self, action: Action, reverse: bool = False): | |
""" | |
アクションを適用または逆適用するメソッド。 | |
:param action: 適用するActionオブジェクト | |
:param reverse: 逆適用する場合はTrue | |
""" | |
if action.action_type == 'start_path': | |
if reverse: | |
if self.paths and self.paths[-1] == action.new_point: | |
self.paths.pop() | |
else: | |
self.paths.append(copy.deepcopy(action.new_point)) | |
elif action.action_type == 'end_path': | |
if reverse: | |
if self.paths and self.paths[action.path_index].is_closed: | |
self.paths[action.path_index].is_closed = False | |
else: | |
if self.paths and not self.paths[action.path_index].is_closed: | |
self.paths[action.path_index].is_closed = True | |
elif action.action_type == 'clear': | |
if reverse: | |
self.paths = copy.deepcopy(action.old_point) | |
else: | |
self.paths.clear() | |
elif action.action_type == 'move': | |
if reverse: | |
for i, p in enumerate(action.old_point): | |
if i < len(self.paths[action.path_index].segments[action.segment_index].points): | |
self.paths[action.path_index].segments[action.segment_index].points[i].x = p.x | |
self.paths[action.path_index].segments[action.segment_index].points[i].y = p.y | |
else: | |
for i, p in enumerate(action.new_point): | |
if i < len(self.paths[action.path_index].segments[action.segment_index].points): | |
self.paths[action.path_index].segments[action.segment_index].points[i].x = p.x | |
self.paths[action.path_index].segments[action.segment_index].points[i].y = p.y | |
elif action.action_type == 'erase': | |
if reverse: | |
# Undo erase: paths_before | |
self.paths = copy.deepcopy(action.paths_before) | |
else: | |
# Redo erase: paths_after | |
self.paths = copy.deepcopy(action.paths_after) | |
elif action.action_type == 'add': | |
path = self.paths[action.path_index] | |
segment = path.segments[action.segment_index] | |
if reverse: | |
# 点の削除 | |
if 0 <= action.point_index < len(segment.points): | |
segment.points.pop(action.point_index) | |
else: | |
# 点の追加 | |
if 0 <= action.point_index <= len(segment.points): | |
segment.points.insert(action.point_index, action.new_point) | |
elif action.action_type == 'remove': | |
path = self.paths[action.path_index] | |
segment = path.segments[action.segment_index] | |
if reverse: | |
# 点の復元 | |
if 0 <= action.point_index <= len(segment.points): | |
segment.points.insert(action.point_index, action.old_point) | |
else: | |
# 点の削除 | |
if 0 <= action.point_index < len(segment.points): | |
segment.points.pop(action.point_index) | |
elif action.action_type == 'pen_draw_path': | |
if reverse: | |
# Undo: パスを削除 | |
if action.path in self.paths: | |
self.paths.remove(action.path) | |
else: | |
# Redo: パスを再追加 | |
self.paths.append(copy.deepcopy(action.path)) | |
self.update() | |
def interpolate_point(self, p1: Point, p2: Point, current: QPointF) -> Point: | |
""" | |
補間されたポイントを計算するメソッド。 | |
:param p1: 前のPointオブジェクト | |
:param p2: 次のPointオブジェクト | |
:param current: 現在の位置 | |
:return: 補間後のPointオブジェクト | |
""" | |
mid_x = (p1.x + p2.x) / 2 | |
mid_y = (p1.y + p2.y) / 2 | |
new_x = mid_x + (current.x() - mid_x) * self.smoothing_factor | |
new_y = mid_y + (current.y() - mid_y) * self.smoothing_factor | |
return Point(new_x, new_y) | |
def set_smoothing_factor(self, factor: float): | |
""" | |
スムージングのファクターを設定するメソッド。 | |
:param factor: 0から1の間の値 | |
""" | |
self.smoothing_factor = max(0, min(1, factor)) # 0から1の間に制限 | |
def set_pressure_curve(self, x_curve, y_curve): | |
""" | |
筆圧カーブを設定するメソッド。 | |
:param x_curve: X軸のカーブデータ | |
:param y_curve: Y軸のカーブデータ | |
""" | |
self.x_curve = x_curve | |
self.y_curve = y_curve | |
def save_as_png(self, filename: str): | |
""" | |
キャンバスをPNG形式で保存するメソッド。 | |
:param filename: 保存先のファイル名 | |
""" | |
image = QImage(self.width, self.height, QImage.Format_ARGB32) | |
image.fill(Qt.white) # 背景を白にする | |
painter = QPainter(image) | |
painter.setRenderHint(QPainter.Antialiasing) | |
# 制御点を一時的に非表示にする | |
original_input_mode = self.input_mode | |
self.input_mode = 'saving' # 新しい保存用モードに切り替え | |
# 描画 | |
self.draw_paths(painter) | |
painter.end() | |
# 入力モードを元に戻す | |
self.input_mode = original_input_mode | |
image.save(filename, "PNG") | |
def save_as_svg(self, filename: str): | |
""" | |
キャンバスをSVG形式で保存するメソッド。 | |
:param filename: 保存先のファイル名 | |
""" | |
from PySide2.QtSvg import QSvgGenerator | |
svg_generator = QSvgGenerator() | |
svg_generator.setFileName(filename) | |
svg_generator.setSize(QSize(self.width, self.height)) | |
svg_generator.setViewBox(QRect(0, 0, self.width, self.height)) | |
svg_generator.setTitle("SVG Drawing") | |
svg_generator.setDescription("An SVG drawing created by LibreSketch") | |
painter = QPainter(svg_generator) | |
painter.setRenderHint(QPainter.Antialiasing) | |
# 制御点を一時的に非表示にする | |
original_input_mode = self.input_mode | |
self.input_mode = 'saving' # 新しい保存用モードに切り替え | |
# 描画 | |
self.draw_paths(painter) | |
painter.end() | |
# 入力モードを元に戻す | |
self.input_mode = original_input_mode | |
def draw_paths(self, painter: QPainter): | |
""" | |
すべてのパスを描画するメソッド。制御点を非表示にするモードで呼び出す。 | |
:param painter: QPainterオブジェクト | |
""" | |
# 背景を白で塗りつぶす | |
painter.fillRect(QRect(0, 0, self.width, self.height), Qt.white) | |
# すべてのパスを描画 | |
for path in self.paths: | |
self.draw_path(painter, path) | |
# 現在描画中のパスを描画 | |
if self.current_path: | |
self.draw_path(painter, self.current_path) | |
class PenTabletSettingsDialog(QDialog): | |
def __init__(self, parent=None, drawing_widget=None): | |
super(PenTabletSettingsDialog, self).__init__(parent) | |
self.drawing_widget = drawing_widget | |
self.setWindowTitle("Pen Tablet Settings") | |
self.resize(600, 600) | |
# 現在の設定を取得 | |
current_settings = self.drawing_widget.get_current_pen_settings() | |
layout = QVBoxLayout() | |
# スムージングスライダー | |
self.smoothing_slider = QSlider(Qt.Horizontal) | |
self.smoothing_slider.setRange(0, 100) | |
self.smoothing_slider.setValue(int(current_settings['smoothing_factor'] * 100)) | |
self.smoothing_slider.valueChanged.connect(self.update_smoothing_label) | |
self.smoothing_label = QLabel(f"Smoothing: {self.smoothing_slider.value()}") | |
layout.addWidget(self.smoothing_label) | |
layout.addWidget(self.smoothing_slider) | |
# 点間隔スライダー | |
self.interval_slider = QSlider(Qt.Horizontal) | |
self.interval_slider.setRange(1, 100) | |
self.interval_slider.setValue(current_settings['point_interval']) | |
self.interval_slider.valueChanged.connect(self.update_interval_label) | |
self.interval_label = QLabel(f"Line Interval: {self.interval_slider.value()}") | |
layout.addWidget(self.interval_label) | |
layout.addWidget(self.interval_slider) | |
# グラフ領域 | |
self.fig, self.ax = plt.subplots() | |
self.canvas = FigureCanvas(self.fig) | |
layout.addWidget(QLabel("Pressure Curve:")) | |
layout.addWidget(self.canvas) | |
# 制御点の初期化 | |
if current_settings['pressure_curve_x'] is not None and current_settings['pressure_curve_y'] is not None: | |
self.control_points_x = current_settings['pressure_curve_x'] | |
self.control_points_y = current_settings['pressure_curve_y'] | |
else: | |
self.control_points_x = [0.0, 0.3, 0.7, 1.0] | |
self.control_points_y = [0.0, 0.1, 0.9, 1.0] | |
# 初期曲線 | |
self.x = np.linspace(0, 1, 100) | |
self.y = self.calculate_curve(self.x) | |
# グラフの初期設定 | |
self.ax.clear() | |
self.line, = self.ax.plot(self.x, self.y) | |
self.control_dots, = self.ax.plot(self.control_points_x, self.control_points_y, 'ro', markersize=8) | |
# X軸とY軸のラベルを設定 | |
self.ax.set_xlabel('Input Pressure (Raw value from device)', fontsize=10) | |
self.ax.set_ylabel('Output Pressure (Applied value)', fontsize=10) | |
# グラフのタイトルを設定 | |
self.ax.set_title('Pressure Curve', fontsize=12) | |
# グリッドを表示 | |
self.ax.grid(True, linestyle='--', alpha=0.7) | |
# 軸の範囲を0から1に設定 | |
self.ax.set_xlim(0, 1) | |
self.ax.set_ylim(0, 1) | |
# グラフを更新 | |
self.fig.tight_layout() | |
self.canvas.draw() | |
# OKボタン | |
self.ok_button = QPushButton("OK") | |
self.ok_button.clicked.connect(self.apply_settings) | |
layout.addWidget(self.ok_button) | |
self.setLayout(layout) | |
# グラフクリックおよびドラッグの管理 | |
self.canvas.mpl_connect('button_press_event', self.on_click) | |
self.canvas.mpl_connect('motion_notify_event', self.on_drag) | |
self.canvas.mpl_connect('button_release_event', self.on_release) | |
self.selected_point = None | |
def update_smoothing_label(self, value: int): | |
"""スムージングのラベルを更新するメソッド。""" | |
self.smoothing_label.setText(f"Smoothing: {value}") | |
def update_interval_label(self, value: int): | |
"""点間隔のラベルを更新するメソッド。""" | |
self.interval_label.setText(f"Line Interval: {value}") | |
def calculate_curve(self, x): | |
""" | |
圧力カーブを計算するメソッド。制御点に基づいて滑らかなカーブを生成。 | |
PchipInterpolatorを使用して、ゆるやかなカーブにします。 | |
""" | |
return PchipInterpolator(self.control_points_x, self.control_points_y)(x) | |
def update_curve(self): | |
self.y = self.calculate_curve(self.x) | |
self.line.set_ydata(self.y) | |
self.control_dots.set_xdata(self.control_points_x) | |
self.control_dots.set_ydata(self.control_points_y) | |
self.ax.relim() | |
self.ax.autoscale_view() | |
self.canvas.draw() | |
def on_click(self, event): | |
""" | |
グラフクリックイベント。制御点を追加、選択する。 | |
""" | |
if event.inaxes != self.ax: | |
return | |
# クリックされた位置に最も近い制御点を探す | |
distances = [abs(event.xdata - x) for x in self.control_points_x] | |
closest_index = np.argmin(distances) | |
if distances[closest_index] < 0.05: # 選択できる距離内ならその制御点を選択 | |
self.selected_point = closest_index | |
elif 0 < event.xdata < 1: # 始点(0,0)と終点(1,1)を固定し、新しい点を範囲内に追加 | |
# 新しい制御点を追加 | |
self.control_points_x = np.append(self.control_points_x, event.xdata) | |
self.control_points_y = np.append(self.control_points_y, event.ydata) | |
sorted_indices = np.argsort(self.control_points_x) | |
self.control_points_x = self.control_points_x[sorted_indices] | |
self.control_points_y = self.control_points_y[sorted_indices] | |
self.update_curve() | |
def on_drag(self, event): | |
""" | |
クリックされている制御点がある場合にその位置を更新する(ドラッグ動作)。 | |
他の制御点は移動しない。始点と終点は固定。 | |
""" | |
if self.selected_point is not None and event.inaxes == self.ax: | |
# 始点(0,0)と終点(1,1)は固定 | |
if self.selected_point == 0 or self.selected_point == len(self.control_points_x) - 1: | |
return | |
# ドラッグに応じて選択された制御点の座標を更新 | |
self.control_points_x[self.selected_point] = event.xdata | |
self.control_points_y[self.selected_point] = event.ydata | |
self.update_curve() | |
def on_release(self, event): | |
""" | |
マウスボタンが離されたときに制御点の移動を終了する。 | |
""" | |
self.selected_point = None # 制御点の選択を解除 | |
def apply_settings(self): | |
"""OKボタンが押されたときに設定を適用するメソッド。""" | |
if self.drawing_widget: | |
settings = { | |
'smoothing_factor': self.smoothing_slider.value() / 100.0, | |
'point_interval': self.interval_slider.value(), | |
'pressure_curve_x': self.control_points_x, | |
'pressure_curve_y': self.control_points_y | |
} | |
self.drawing_widget.set_pen_settings(settings) | |
# interval_slider_value を MainWindow に反映 | |
self.drawing_widget.main_window.interval_slider_value = self.interval_slider.value() | |
self.accept() | |
class WidthDialog(QDialog): | |
def __init__(self, parent=None, current_line_width=2): | |
super(WidthDialog, self).__init__(parent) | |
self.setWindowTitle("Select Line Width") | |
self.resize(300, 150) | |
layout = QVBoxLayout() | |
# 線の太さスライダー | |
self.slider = QSlider(Qt.Horizontal) | |
self.slider.setRange(1, 20) | |
self.slider.setValue(current_line_width) | |
self.slider.valueChanged.connect(self.update_label) | |
self.label = QLabel(f"Width: {current_line_width}") | |
layout.addWidget(self.label) | |
layout.addWidget(self.slider) | |
# OKボタン | |
self.ok_button = QPushButton("OK") | |
self.ok_button.clicked.connect(self.accept) | |
layout.addWidget(self.ok_button) | |
self.setLayout(layout) | |
def update_label(self, value: int): | |
"""スライダーの値に応じてラベルを更新するメソッド。""" | |
self.label.setText(f"Width: {value}") | |
def get_width_value(self) -> int: | |
""" | |
スライダーの値(選択した線の太さ)を返すメソッド。 | |
:return: 線の太さ | |
""" | |
return self.slider.value() | |
class EraserDialog(QDialog): | |
""" | |
消しゴムの設定を行うダイアログクラス。 | |
スライダーを使用して消しゴムの直径を調整できる。 | |
""" | |
def __init__(self, parent=None): | |
super(EraserDialog, self).__init__(parent) | |
self.setWindowTitle("消しゴムの設定") | |
self.resize(300, 150) | |
layout = QVBoxLayout() | |
# 消しゴム直径スライダー | |
self.slider = QSlider(Qt.Horizontal) | |
self.slider.setRange(5, 100) | |
self.slider.setValue(20) # デフォルトの消しゴム直径 | |
self.slider.valueChanged.connect(self.update_label) | |
self.label = QLabel("消しゴムの直径: 20") | |
layout.addWidget(self.label) | |
layout.addWidget(self.slider) | |
# OKボタン | |
self.ok_button = QPushButton("OK") | |
self.ok_button.clicked.connect(self.accept) | |
layout.addWidget(self.ok_button) | |
self.setLayout(layout) | |
def update_label(self, value: int): | |
"""スライダーの値に応じてラベルを更新するメソッド。""" | |
self.label.setText(f"消しゴムの直径: {value}") | |
def get_eraser_size(self) -> int: | |
"""選択した消しゴムの直径を返すメソッド。""" | |
return self.slider.value() | |
class NewCanvasDialog(QDialog): | |
""" | |
新しいキャンバスを作成するためのダイアログクラス。 | |
キャンバスの幅、高さ、DPIを入力できる。 | |
""" | |
def __init__(self, parent=None): | |
super(NewCanvasDialog, self).__init__(parent) | |
self.setWindowTitle("New Canvas") | |
self.resize(300, 200) | |
layout = QVBoxLayout() | |
# 幅入力 | |
self.width_input = QLineEdit() | |
self.width_input.setText("1024") | |
layout.addWidget(QLabel("Width (pixels):")) | |
layout.addWidget(self.width_input) | |
# 高さ入力 | |
self.height_input = QLineEdit() | |
self.height_input.setText("1024") | |
layout.addWidget(QLabel("Height (pixels):")) | |
layout.addWidget(self.height_input) | |
# DPI入力 | |
self.dpi_input = QLineEdit() | |
self.dpi_input.setText("350") | |
layout.addWidget(QLabel("DPI:")) | |
layout.addWidget(self.dpi_input) | |
# ボタンボックス(OKとキャンセル) | |
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) | |
button_box.accepted.connect(self.accept) | |
button_box.rejected.connect(self.reject) | |
layout.addWidget(button_box) | |
self.setLayout(layout) | |
def get_values(self) -> tuple: | |
""" | |
入力された幅、高さ、DPIの値を取得するメソッド。 | |
:return: (幅, 高さ, DPI) のタプル | |
""" | |
width = int(self.width_input.text()) | |
height = int(self.height_input.text()) | |
dpi = int(self.dpi_input.text()) | |
return width, height, dpi | |
class MainWindow(QMainWindow): | |
""" | |
アプリケーションのメインウィンドウクラス。 | |
メニュー、ツールバー、MDIエリアを管理し、各種アクションを処理する。 | |
""" | |
def __init__(self): | |
super(MainWindow, self).__init__() | |
self.setWindowTitle("LibreSketch") | |
# 画面の解像度を取得 | |
screen = QDesktopWidget().screenNumber(QDesktopWidget().cursor().pos()) | |
screen_size = QDesktopWidget().screenGeometry(screen).size() | |
# 最小サイズを設定(例:640x480) | |
self.setMinimumSize(640, 480) | |
# ウィンドウを最大化 | |
self.showMaximized() | |
# ショートカットのロード | |
self.shortcuts = self.load_shortcuts() | |
# MDIエリアの設定 | |
self.mdi_area = QMdiArea() | |
self.setCentralWidget(self.mdi_area) | |
# メニューバーとツールバーの作成 | |
self.create_menubar() | |
self.create_toolbar() | |
# 初期入力モードをペンタブモードに設定 | |
self.change_input_mode('pen_tablet') | |
# サブウィンドウがアクティブになったときの接続 | |
self.mdi_area.subWindowActivated.connect(self.update_toolbar_for_active_subwindow) | |
self.mdi_area.installEventFilter(self) | |
# ペンタブレット設定用の初期値を設定 | |
self.interval_slider_value = 20 # 初期値として20を設定 | |
self.smoothing_factor_value = 0.5 # スムージングファクターの初期値 | |
# 消しゴムツールの設定 | |
self.eraser_size = 20 # デフォルトの消しゴムのサイズ | |
def load_shortcuts(self) -> dict: | |
""" | |
ショートカットキーの設定をINIファイルからロードするメソッド。 | |
デフォルトのショートカットが設定ファイルに存在しない場合は作成する。 | |
:return: ショートカットキーの辞書 | |
""" | |
# デフォルトのショートカット設定 | |
default_shortcuts = { | |
'zoom_in': 'Ctrl+Plus', | |
'zoom_out': 'Ctrl+Minus', | |
'flip_horizontal': 'H', | |
'rotate_clockwise': 'R', | |
'rotate_counterclockwise': 'L', | |
'change_to_mouse_mode': 'M', | |
'change_to_pen_tablet_mode': 'T', | |
'change_to_control_points_mode': 'P', | |
'change_to_eraser_mode': 'E', | |
'undo': 'Ctrl+Z', | |
'redo': 'Ctrl+Y', | |
'clear_canvas': 'Delete', | |
'undo': 'Ctrl+Z', | |
'redo': 'Ctrl+Y', | |
} | |
config = configparser.ConfigParser() | |
if not os.path.exists(INI_DIR): | |
# settings.iniが存在しない場合、デフォルト設定で作成 | |
config['Shortcuts'] = default_shortcuts | |
with open(INI_DIR, 'w') as configfile: | |
config.write(configfile) | |
return default_shortcuts | |
else: | |
# INIファイルからショートカットを上書き | |
config.read(INI_DIR) | |
if 'Shortcuts' in config: | |
for key in config['Shortcuts']: | |
default_shortcuts[key] = config['Shortcuts'][key] | |
return default_shortcuts | |
def create_menubar(self): | |
""" | |
メニューバーを作成し、各メニューとアクションを追加するメソッド。 | |
""" | |
menubar = self.menuBar() | |
# ファイルメニューの作成 | |
file_menu = menubar.addMenu("File") | |
# 新規作成アクション | |
new_action = QAction(load_icon("new_icon.png"), "New", self) | |
new_action.setShortcut(self.shortcuts.get('new', 'Ctrl+N')) | |
new_action.triggered.connect(self.new_canvas) | |
file_menu.addAction(new_action) | |
# PNGとして保存アクション | |
save_png_action = QAction(load_icon("save_png_icon.png"), "Save as PNG", self) | |
save_png_action.triggered.connect(self.save_as_png) | |
file_menu.addAction(save_png_action) | |
# SVGとして保存アクション | |
save_svg_action = QAction(load_icon("save_svg_icon.png"), "Save as SVG", self) | |
save_svg_action.triggered.connect(self.save_as_svg) | |
file_menu.addAction(save_svg_action) | |
def create_toolbar(self): | |
""" | |
ツールバーを作成し、各アクションやボタンを追加するメソッド。 | |
""" | |
self.toolbar = QToolBar() | |
self.addToolBar(self.toolbar) | |
# Undoアクション | |
undo_action = QAction(load_icon("undo_icon.png"), "Undo", self) | |
undo_action.setShortcut(self.shortcuts.get('undo', 'Ctrl+Z')) | |
undo_action.triggered.connect(self.undo_active_canvas) | |
self.toolbar.addAction(undo_action) | |
# Redoアクション | |
redo_action = QAction(load_icon("redo_icon.png"), "Redo", self) | |
redo_action.setShortcut(self.shortcuts.get('redo', 'Ctrl+Y')) | |
redo_action.triggered.connect(self.redo_active_canvas) | |
self.toolbar.addAction(redo_action) | |
# クリアアクション | |
clear_action = QAction(load_icon("clear_icon.png"), "Clear", self) | |
clear_action.setShortcut(self.shortcuts.get('clear_canvas', 'Delete')) | |
clear_action.triggered.connect(self.clear_active_canvas) | |
self.toolbar.addAction(clear_action) | |
# 入力モード選択ボタン | |
self.input_mode_button = QToolButton() | |
self.input_mode_button.setText("モード") | |
self.input_mode_button.setIcon(load_icon("pen_tablet_icon.png")) | |
self.input_mode_button.setPopupMode(QToolButton.MenuButtonPopup) | |
input_mode_menu = QMenu(self.input_mode_button) | |
self.input_mode_actions = [] | |
# ペンタブレットモードアクション | |
pen_tablet_action = QAction(load_icon("pen_tablet_icon.png"), "ペンタブレットモード", self) | |
pen_tablet_action.setCheckable(True) | |
pen_tablet_action.setChecked(True) # ペンタブレットモードを初期選択状態に | |
pen_tablet_action.setData('pen_tablet') | |
pen_tablet_action.triggered.connect(lambda: self.change_input_mode('pen_tablet')) | |
self.input_mode_actions.append(pen_tablet_action) | |
# マウスモードアクション | |
mouse_action = QAction(load_icon("mouse_icon.png"), "マウスモード", self) | |
mouse_action.setCheckable(True) | |
mouse_action.setData('mouse') | |
mouse_action.triggered.connect(lambda: self.change_input_mode('mouse')) | |
self.input_mode_actions.append(mouse_action) | |
# 制御点モードアクション | |
control_point_action = QAction(load_icon("control_points_icon.png"), "制御点モード", self) | |
control_point_action.setCheckable(True) | |
control_point_action.setData('control_points') | |
control_point_action.triggered.connect(lambda: self.change_input_mode('control_points')) | |
self.input_mode_actions.append(control_point_action) | |
# 消しゴムツールアクション | |
eraser_action = QAction(load_icon("eraser_icon.png"), "消しゴムモード", self) | |
eraser_action.setCheckable(True) | |
eraser_action.setData('eraser') | |
eraser_action.triggered.connect(lambda: self.change_input_mode('eraser')) | |
self.input_mode_actions.append(eraser_action) | |
# メニューにアクションを追加 | |
input_mode_menu.addAction(mouse_action) | |
input_mode_menu.addAction(pen_tablet_action) | |
input_mode_menu.addAction(control_point_action) | |
input_mode_menu.addAction(eraser_action) | |
self.input_mode_button.setMenu(input_mode_menu) | |
self.toolbar.addWidget(self.input_mode_button) | |
# 線/曲線モード選択ボタン | |
self.line_mode_button = QToolButton() | |
self.line_mode_button.setText("Line/Curve Mode") | |
self.line_mode_button.setIcon(load_icon("curve_icon.png")) | |
self.line_mode_button.setPopupMode(QToolButton.MenuButtonPopup) | |
line_mode_menu = QMenu(self.line_mode_button) | |
line_action = QAction(load_icon("line_icon.png"), "Line Mode", self) | |
line_action.setCheckable(True) | |
line_action.triggered.connect(lambda: self.change_line_mode('line')) | |
curve_action = QAction(load_icon("curve_icon.png"), "Curve Mode", self) | |
curve_action.setCheckable(True) | |
curve_action.setChecked(False) | |
curve_action.triggered.connect(lambda: self.change_line_mode('curve')) | |
line_mode_menu.addAction(line_action) | |
line_mode_menu.addAction(curve_action) | |
self.line_mode_button.setMenu(line_mode_menu) | |
self.mode_button_action = self.toolbar.addWidget(self.line_mode_button) | |
self.toolbar.removeAction(self.mode_button_action) | |
# ペンタブレット設定ボタン | |
self.pen_settings_button = QAction(load_icon("pen_setting_icon.png"), "Pen Tablet Settings", self) | |
self.pen_settings_button.triggered.connect(self.show_pen_tablet_settings) | |
self.pen_settings_button.setVisible(True) | |
self.toolbar.addAction(self.pen_settings_button) | |
# 消しゴム設定ボタン | |
self.eraser_settings_button = QAction(load_icon("eraser_setting_icon.png"), "Set Eraaser Size", self) | |
self.eraser_settings_button.triggered.connect(self.show_eraser_settings) | |
self.eraser_settings_button.setVisible(False) # 消しゴムモード以外では非表示 | |
self.toolbar.addAction(self.eraser_settings_button) | |
# 線の太さ設定ボタン | |
self.width_button = QAction(load_icon("width_icon.png"), "Set Line Width", self) | |
self.width_button.triggered.connect(self.show_line_width_dialog) | |
self.toolbar.addAction(self.width_button) | |
# ズームインボタン | |
zoom_in_action = QAction(load_icon("zoom_in_icon.png"), "Zoom In", self) | |
zoom_in_action.triggered.connect(self.zoom_in_active_canvas) | |
self.toolbar.addAction(zoom_in_action) | |
# ズームアウトボタン | |
zoom_out_action = QAction(load_icon("zoom_out_icon.png"), "Zoom Out", self) | |
zoom_out_action.triggered.connect(self.zoom_out_active_canvas) | |
self.toolbar.addAction(zoom_out_action) | |
# 左右反転ボタン | |
flip_horizontal_action = QAction(load_icon("flip_horizontal_icon.png"), "Flip Horizontal", self) | |
flip_horizontal_action.triggered.connect(self.flip_horizontal_active_canvas) | |
self.toolbar.addAction(flip_horizontal_action) | |
# 時計回り回転ボタン | |
rotate_cw_action = QAction(load_icon("rotate_cw_icon.png"), "Rotate Clockwise", self) | |
rotate_cw_action.triggered.connect(self.rotate_cw_active_canvas) | |
self.toolbar.addAction(rotate_cw_action) | |
# 反時計回り回転ボタン | |
rotate_ccw_action = QAction(load_icon("rotate_ccw_icon.png"), "Rotate Counterclockwise", self) | |
rotate_ccw_action.triggered.connect(self.rotate_ccw_active_canvas) | |
self.toolbar.addAction(rotate_ccw_action) | |
def show_line_width_dialog(self): | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
current_line_width = drawing_widget.get_current_line_width() | |
dialog = WidthDialog(self, current_line_width) | |
if dialog.exec_() == QDialog.Accepted: | |
selected_width = dialog.get_width_value() | |
drawing_widget.set_width(selected_width) | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "線の太さを調整するキャンバスがありません。") | |
def show_pen_tablet_settings(self): | |
""" | |
ペンタブレット設定ダイアログを表示するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
dialog = PenTabletSettingsDialog(self, drawing_widget) | |
dialog.exec_() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "設定を調整するキャンバスがありません。") | |
def show_curve_editor(self): | |
""" | |
筆圧カーブの編集ダイアログを表示するメソッド。 | |
""" | |
dialog = CurveEditorDialog(self) | |
if dialog.exec_() == QDialog.Accepted: | |
x_curve, y_curve = dialog.get_curve() | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.set_pressure_curve(x_curve, y_curve) | |
def show_eraser_settings(self): | |
"""消しゴム設定ダイアログを表示するメソッド。""" | |
dialog = EraserDialog(self) | |
if dialog.exec_() == QDialog.Accepted: | |
self.eraser_size = dialog.get_eraser_size() | |
else: | |
QMessageBox.warning(self, "設定の適用に失敗しました", "消しゴムの設定が適用されませんでした。") | |
def save_as_png(self): | |
""" | |
現在アクティブなキャンバスをPNG形式で保存するメソッド。 | |
""" | |
active_subwindow = self.mdi_area.activeSubWindow() | |
if active_subwindow: | |
scroll_area = active_subwindow.widget() | |
if isinstance(scroll_area, QScrollArea): | |
drawing_widget = scroll_area.widget() | |
filename, _ = QFileDialog.getSaveFileName(self, "Save as PNG", "", "PNG Files (*.png)") | |
if filename and hasattr(drawing_widget, 'save_as_png'): | |
drawing_widget.save_as_png(filename) | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "保存するキャンバスがありません。") | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "保存するキャンバスがありません。") | |
def save_as_svg(self): | |
""" | |
現在アクティブなキャンバスをSVG形式で保存するメソッド。 | |
""" | |
active_subwindow = self.mdi_area.activeSubWindow() | |
if active_subwindow: | |
scroll_area = active_subwindow.widget() | |
if isinstance(scroll_area, QScrollArea): | |
drawing_widget = scroll_area.widget() | |
filename, _ = QFileDialog.getSaveFileName(self, "Save as SVG", "", "SVG Files (*.svg)") | |
if filename and hasattr(drawing_widget, 'save_as_svg'): | |
drawing_widget.save_as_svg(filename) | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "保存するキャンバスがありません。") | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "保存するキャンバスがありません。") | |
def new_canvas(self): | |
""" | |
新しいキャンバスを作成するメソッド。 | |
ダイアログから入力を取得し、MDIサブウィンドウとして追加する。 | |
""" | |
dialog = NewCanvasDialog(self) | |
if dialog.exec_() == QDialog.Accepted: | |
width, height, dpi = dialog.get_values() | |
drawing_widget = DrawingWidget(self, width=width, height=height, dpi=dpi, main_window=self) | |
scroll_area = QScrollArea() | |
scroll_area.setWidget(drawing_widget) | |
scroll_area.setAlignment(Qt.AlignCenter) # 中央に配置 | |
# サブウィンドウの作成 | |
subwindow = QMdiSubWindow() | |
subwindow.setWidget(scroll_area) | |
subwindow.setAttribute(Qt.WA_DeleteOnClose) | |
# MDIエリアにサブウィンドウを追加 | |
self.mdi_area.addSubWindow(subwindow) | |
# サブウィンドウを最大化 | |
subwindow.showMaximized() | |
self.mdi_area.setActiveSubWindow(subwindow) | |
# # 表示後に位置調整を自由にする | |
# scroll_area.setAlignment(Qt.AlignLeft | Qt.AlignTop) | |
def get_active_drawing_widget(self) -> DrawingWidget: | |
""" | |
現在アクティブなサブウィンドウ内のDrawingWidgetを取得するメソッド。 | |
:return: アクティブなDrawingWidgetオブジェクト、存在しない場合はNone | |
""" | |
active_subwindow = self.mdi_area.activeSubWindow() | |
if active_subwindow: | |
scroll_area = active_subwindow.widget() | |
if isinstance(scroll_area, QScrollArea): | |
return scroll_area.widget() | |
return None | |
def clear_active_canvas(self): | |
""" | |
現在アクティブなキャンバスをクリアするメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.clear_canvas() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "クリアするキャンバスがありません。") | |
def undo_active_canvas(self): | |
""" | |
現在アクティブなキャンバスでUndo操作を実行するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.undo() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "元に戻すキャンバスがありません。") | |
def redo_active_canvas(self): | |
""" | |
現在アクティブなキャンバスでRedo操作を実行するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.redo() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "やり直すキャンバスがありません。") | |
def change_line_mode(self, mode: str): | |
""" | |
現在アクティブなキャンバスの線モードを変更するメソッド。 | |
:param mode: 'line' または 'curve' | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.set_mode(mode) | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "モードを変更するキャンバスがありません。") | |
def change_input_mode(self, mode: str, temporary: bool = False): | |
""" | |
現在アクティブなキャンバスの入力モードを変更するメソッド。 | |
:param mode: 'mouse', 'pen_tablet', 'control_points' | |
:param temporary: 一時的な変更かどうか | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
# 制御点が1つしかない場合、削除する処理を追加 | |
for path in drawing_widget.paths: | |
for segment in path.segments: | |
if len(segment.points) == 1: | |
segment.points.clear() # 制御点をクリア | |
# 操作履歴に追加(任意) | |
action = Action('clear_point', 0, 0, 0, old_point=copy.deepcopy(segment.points)) | |
drawing_widget.save_action(action) | |
# 現在のパスを閉じる | |
drawing_widget.end_drawing() | |
# 入力モードを設定 | |
drawing_widget.set_input_mode(mode) | |
# UI要素を適切に更新 | |
if mode == 'mouse': | |
self.input_mode_button.setIcon(load_icon("mouse_icon.png")) | |
self.toggle_mouse() | |
elif mode == 'pen_tablet': | |
self.input_mode_button.setIcon(load_icon("pen_tablet_icon.png")) | |
self.toggle_pen_tablet() | |
elif mode == 'control_points': | |
self.input_mode_button.setIcon(load_icon("control_points_icon.png")) | |
self.toggle_control_points() | |
elif mode == 'eraser': | |
self.input_mode_button.setIcon(load_icon("eraser_icon.png")) | |
self.toggle_eraser() | |
# 一時的な変更でない場合は、メニューのチェック状態を更新 | |
if not temporary: | |
self.update_input_mode_menu(mode) | |
drawing_widget.update() | |
def update_input_mode_menu(self, mode: str): | |
""" | |
入力モードメニューのチェック状態を更新するメソッド。 | |
:param mode: 現在の入力モード | |
""" | |
for action in self.input_mode_actions: | |
action.setChecked(action.data() == mode) | |
def toggle_mouse(self): | |
""" | |
マウスモードに応じてツールバーのUIを更新するメソッド。 | |
""" | |
if self.mode_button_action not in self.toolbar.actions(): | |
self.toolbar.insertAction(self.pen_settings_button, self.mode_button_action) | |
self.pen_settings_button.setVisible(False) | |
self.width_button.setVisible(True) | |
self.eraser_settings_button.setVisible(False) | |
def toggle_pen_tablet(self): | |
""" | |
ペンタブレットモードに応じてツールバーのUIを更新するメソッド。 | |
""" | |
if self.mode_button_action in self.toolbar.actions(): | |
self.toolbar.removeAction(self.mode_button_action) | |
self.pen_settings_button.setVisible(True) | |
self.width_button.setVisible(True) | |
self.eraser_settings_button.setVisible(False) | |
def toggle_control_points(self): | |
""" | |
制御点モードに応じてツールバーのUIを更新するメソッド。 | |
""" | |
if self.mode_button_action in self.toolbar.actions(): | |
self.toolbar.removeAction(self.mode_button_action) | |
self.pen_settings_button.setVisible(False) | |
self.width_button.setVisible(False) | |
self.eraser_settings_button.setVisible(False) | |
def toggle_eraser(self): | |
"""消しゴムモードに応じてツールバーのUIを更新するメソッド。""" | |
if self.mode_button_action in self.toolbar.actions(): | |
self.toolbar.removeAction(self.mode_button_action) | |
self.pen_settings_button.setVisible(False) | |
self.width_button.setVisible(False) | |
self.eraser_settings_button.setVisible(True) | |
def update_ui_for_input_mode(self, mode: str): | |
""" | |
入力モードが変更されたときにUIを更新するメソッド。 | |
:param mode: 新しい入力モード | |
""" | |
self.change_input_mode(mode) | |
def update_toolbar_for_active_subwindow(self, subwindow): | |
""" | |
アクティブなサブウィンドウが変更されたときにツールバーを更新するメソッド。 | |
必要に応じて実装を追加。 | |
:param subwindow: アクティブになったサブウィンドウ | |
""" | |
pass # 必要に応じてツールバーを更新 | |
def zoom_in_active_canvas(self): | |
""" | |
現在アクティブなキャンバスをズームインするメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.zoom_in() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "ズームインするキャンバスがありません。") | |
def zoom_out_active_canvas(self): | |
""" | |
現在アクティブなキャンバスをズームアウトするメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.zoom_out() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "ズームアウトするキャンバスがありません。") | |
def flip_horizontal_active_canvas(self): | |
""" | |
現在アクティブなキャンバスを左右反転するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.flip_horizontal() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "反転するキャンバスがありません。") | |
def rotate_cw_active_canvas(self): | |
""" | |
現在アクティブなキャンバスを時計回りに回転するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.rotate_clockwise() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "回転するキャンバスがありません。") | |
def rotate_ccw_active_canvas(self): | |
""" | |
現在アクティブなキャンバスを反時計回りに回転するメソッド。 | |
""" | |
drawing_widget = self.get_active_drawing_widget() | |
if drawing_widget: | |
drawing_widget.rotate_counterclockwise() | |
else: | |
QMessageBox.warning(self, "アクティブなキャンバスがありません", "回転するキャンバスがありません。") | |
def eventFilter(self, obj, event) -> bool: | |
""" | |
イベントフィルターをオーバーライドするメソッド。 | |
MDIエリアに追加されたサブウィンドウにイベントフィルターを適用する。 | |
:param obj: イベントを送信したオブジェクト | |
:param event: イベントオブジェクト | |
:return: イベントを処理した場合はTrue | |
""" | |
if obj == self.mdi_area and event.type() == QEvent.ChildAdded: | |
if isinstance(event.child(), QMdiSubWindow): | |
event.child().installEventFilter(self) | |
return super().eventFilter(obj, event) | |
if __name__ == "__main__": | |
""" | |
アプリケーションのエントリーポイント。 | |
QApplicationを作成し、MainWindowを表示する。 | |
""" | |
app = QApplication(sys.argv) | |
window = MainWindow() | |
window.show() | |
sys.exit(app.exec_()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment