Coverage for paintmaskanimator/pressure.py: 74%
324 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1import math
3from PySide6.QtCore import QPointF, Qt, Signal
4from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen
5from PySide6.QtWidgets import (
6 QCheckBox,
7 QComboBox,
8 QDialog,
9 QDialogButtonBox,
10 QFormLayout,
11 QGraphicsOpacityEffect,
12 QHBoxLayout,
13 QLabel,
14 QPushButton,
15 QRadioButton,
16 QSlider,
17 QVBoxLayout,
18 QWidget,
19)
21from .i18n import tr
22from .logging_setup import get_logger
24log = get_logger(__name__)
27def _pressure_bezier_segments(points):
28 """形状を保ちながら滑らかにつながる区分3次ベジエ曲線を生成する。"""
29 ordered = []
30 for x, y in sorted(
31 (
32 max(0.0, min(1.0, float(x))),
33 max(0.0, min(1.0, float(y))),
34 )
35 for x, y in points
36 ):
37 if ordered and abs(x - ordered[-1][0]) < 1e-6:
38 ordered[-1] = (x, y)
39 else:
40 ordered.append((x, y))
42 if len(ordered) < 2:
43 ordered = [(0.0, 0.0), (1.0, 1.0)]
45 count = len(ordered)
46 widths = [
47 max(1e-6, ordered[index + 1][0] - ordered[index][0])
48 for index in range(count - 1)
49 ]
50 slopes = [
51 (ordered[index + 1][1] - ordered[index][1]) / widths[index]
52 for index in range(count - 1)
53 ]
55 tangents = [0.0] * count
56 tangents[0] = slopes[0]
57 tangents[-1] = slopes[-1]
58 for index in range(1, count - 1):
59 left = slopes[index - 1]
60 right = slopes[index]
61 if left == 0.0 or right == 0.0 or left * right <= 0.0:
62 tangents[index] = 0.0
63 else:
64 left_width = widths[index - 1]
65 right_width = widths[index]
66 weight_left = 2.0 * right_width + left_width
67 weight_right = right_width + 2.0 * left_width
68 tangents[index] = (
69 weight_left + weight_right
70 ) / (
71 weight_left / left
72 + weight_right / right
73 )
75 for index, slope in enumerate(slopes):
76 if abs(slope) < 1e-12:
77 tangents[index] = 0.0
78 tangents[index + 1] = 0.0
79 continue
80 alpha = tangents[index] / slope
81 beta = tangents[index + 1] / slope
82 magnitude = alpha * alpha + beta * beta
83 if magnitude > 9.0: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true
84 scale = 3.0 / math.sqrt(magnitude)
85 tangents[index] = scale * alpha * slope
86 tangents[index + 1] = scale * beta * slope
88 segments = []
89 for index in range(count - 1):
90 p1 = ordered[index]
91 p2 = ordered[index + 1]
92 width = widths[index]
93 c1 = (
94 p1[0] + width / 3.0,
95 p1[1] + tangents[index] * width / 3.0,
96 )
97 c2 = (
98 p2[0] - width / 3.0,
99 p2[1] - tangents[index + 1] * width / 3.0,
100 )
101 c1 = (
102 max(p1[0], min(p2[0], c1[0])),
103 max(0.0, min(1.0, c1[1])),
104 )
105 c2 = (
106 max(p1[0], min(p2[0], c2[0])),
107 max(0.0, min(1.0, c2[1])),
108 )
109 segments.append((p1, c1, c2, p2))
110 return segments
113def _cubic_bezier_value(a, b, c, d, t):
114 inv = 1.0 - t
115 return (
116 inv * inv * inv * a
117 + 3.0 * inv * inv * t * b
118 + 3.0 * inv * t * t * c
119 + t * t * t * d
120 )
123def _pressure_bezier_at(points, pressure):
124 """入力Xに対応する区分3次ベジエ曲線上のYを返す。"""
125 x = max(0.0, min(1.0, float(pressure)))
126 segments = _pressure_bezier_segments(points)
127 segment = segments[-1]
128 for candidate in segments: 128 ↛ 132line 128 didn't jump to line 132 because the loop on line 128 didn't complete
129 if x <= candidate[3][0]:
130 segment = candidate
131 break
132 p0, c1, c2, p3 = segment
133 if x <= p0[0]:
134 return max(0.0, min(1.0, p0[1]))
135 if x >= p3[0]:
136 return max(0.0, min(1.0, p3[1]))
138 # ベジエ曲線のX(t)=入力値となるtを二分探索する。
139 low, high = 0.0, 1.0
140 for _ in range(32):
141 t = (low + high) * 0.5
142 bx = _cubic_bezier_value(p0[0], c1[0], c2[0], p3[0], t)
143 if bx < x:
144 low = t
145 else:
146 high = t
147 t = (low + high) * 0.5
148 y = _cubic_bezier_value(p0[1], c1[1], c2[1], p3[1], t)
149 return max(0.0, min(1.0, y))
152class PressureCurveWidget(QWidget):
153 """アンカーポイントを追加・移動・削除できる筆圧ベジエカーブ。"""
154 curveChanged = Signal(object)
156 def __init__(self, curve: float | list | tuple = 1.0, parent=None):
157 super().__init__(parent)
158 self._points = self._parse(curve)
159 self._active_index = None
160 self.setMinimumSize(260, 170)
161 self.setMouseTracking(True)
162 self.setToolTip(
163 tr("左クリック:アンカーポイント追加/ドラッグ:移動/"
164 "右クリック:中間アンカーポイント削除/曲線:3次ベジエ補間")
165 )
167 @staticmethod
168 def _parse(curve: float | list | tuple):
169 if isinstance(curve, (list, tuple)):
170 # 点が2つ未満なら、下の直線へのフォールバックに任せる。
171 points = []
172 for point in curve:
173 try:
174 x, y = float(point[0]), float(point[1])
175 points.append((max(0.0, min(1.0, x)), max(0.0, min(1.0, y))))
176 except (TypeError, ValueError, IndexError, KeyError) as exc:
177 log.debug("skipping malformed pressure-curve point %r: %s", point, exc)
178 continue
179 points = sorted(points, key=lambda value: value[0])
180 else:
181 exponent = max(0.20, min(4.0, float(curve)))
182 points = [(0.0, 0.0), (0.5, 0.5 ** exponent), (1.0, 1.0)]
183 if len(points) < 2: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 points = [(0.0, 0.0), (1.0, 1.0)]
185 points[0] = (0.0, points[0][1])
186 points[-1] = (1.0, points[-1][1])
187 return points
189 def points(self):
190 return [[float(x), float(y)] for x, y in self._points]
192 def set_points(self, curve):
193 """Replace the curve without emitting ``curveChanged``."""
194 self._points = self._parse(curve)
195 self._active_index = None
196 self.update()
198 def exponent(self):
199 # 旧形式との互換用。保存・描画では points() を使用する。
200 return 1.0
202 def _graph_rect(self):
203 return self.rect().adjusted(28, 12, -12, -24)
205 def _point_to_value(self, point):
206 graph = self._graph_rect()
207 x = (point.x() - graph.left()) / max(1, graph.width())
208 y = 1.0 - (point.y() - graph.top()) / max(1, graph.height())
209 return max(0.0, min(1.0, x)), max(0.0, min(1.0, y))
211 def _value_to_point(self, x, y):
212 graph = self._graph_rect()
213 return QPointF(
214 graph.left() + x * graph.width(),
215 graph.bottom() - y * graph.height(),
216 )
218 def _nearest_index(self, point, radius=10.0):
219 nearest = None
220 nearest_distance = radius * radius
221 for index, (x, y) in enumerate(self._points):
222 screen = self._value_to_point(x, y)
223 dx = screen.x() - point.x()
224 dy = screen.y() - point.y()
225 distance = dx * dx + dy * dy
226 if distance <= nearest_distance:
227 nearest = index
228 nearest_distance = distance
229 return nearest
231 def _emit_changed(self):
232 self.curveChanged.emit(self.points())
233 self.update()
235 def mousePressEvent(self, event):
236 point = event.position()
237 if event.button() == Qt.MouseButton.RightButton:
238 index = self._nearest_index(point)
239 if index is not None and 0 < index < len(self._points) - 1:
240 del self._points[index]
241 self._active_index = None
242 self._emit_changed()
243 event.accept()
244 return
245 if event.button() == Qt.MouseButton.LeftButton:
246 index = self._nearest_index(point)
247 if index is None:
248 x, y = self._point_to_value(point)
249 self._points.append((x, y))
250 self._points.sort(key=lambda value: value[0])
251 index = min(
252 range(len(self._points)),
253 key=lambda i: abs(self._points[i][0] - x) + abs(self._points[i][1] - y),
254 )
255 self._emit_changed()
256 self._active_index = index
257 event.accept()
258 return
259 super().mousePressEvent(event)
261 def mouseMoveEvent(self, event):
262 if self._active_index is not None and event.buttons() & Qt.MouseButton.LeftButton:
263 x, y = self._point_to_value(event.position())
264 index = self._active_index
265 if index == 0:
266 x = 0.0
267 elif index == len(self._points) - 1:
268 x = 1.0
269 else:
270 left = self._points[index - 1][0] + 0.005
271 right = self._points[index + 1][0] - 0.005
272 x = max(left, min(right, x))
273 self._points[index] = (x, y)
274 self._emit_changed()
275 event.accept()
276 return
277 super().mouseMoveEvent(event)
279 def mouseReleaseEvent(self, event):
280 if event.button() == Qt.MouseButton.LeftButton:
281 self._active_index = None
282 super().mouseReleaseEvent(event)
284 def paintEvent(self, event):
285 painter = QPainter(self)
286 painter.fillRect(self.rect(), QColor(36, 36, 36))
287 graph = self._graph_rect()
288 painter.setPen(QPen(QColor(90, 90, 90), 1))
289 for i in range(5):
290 x = graph.left() + graph.width() * i / 4
291 y = graph.top() + graph.height() * i / 4
292 painter.drawLine(QPointF(x, graph.top()), QPointF(x, graph.bottom()))
293 painter.drawLine(QPointF(graph.left(), y), QPointF(graph.right(), y))
294 painter.setPen(QPen(QColor(210, 210, 210), 1))
295 painter.drawRect(graph)
296 path = QPainterPath()
297 segments = _pressure_bezier_segments(self._points)
298 first_point = self._value_to_point(*segments[0][0])
299 path.moveTo(first_point)
300 for _start, control1, control2, end in segments:
301 path.cubicTo(
302 self._value_to_point(*control1),
303 self._value_to_point(*control2),
304 self._value_to_point(*end),
305 )
306 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
307 painter.setPen(QPen(QColor(255, 210, 70), 2))
308 painter.drawPath(path)
309 painter.setBrush(QColor(255, 255, 255))
310 painter.setPen(QPen(QColor(255, 210, 70), 2))
311 for x, y in self._points:
312 painter.drawEllipse(self._value_to_point(x, y), 5, 5)
313 painter.setPen(QColor(220, 220, 220))
314 painter.drawText(4, graph.top() + 10, tr("出力"))
315 painter.drawText(graph.right() - 24, self.height() - 5, tr("入力"))
316 painter.end()
319class PressureEditor(QWidget):
320 """On/off, minimum, maximum and curve of one pressure setting.
322 ``changed`` fires on every user edit; :meth:`set_settings` does not fire it.
323 """
325 changed = Signal()
327 def __init__(self, settings=None, parent=None):
328 super().__init__(parent)
329 layout = QVBoxLayout(self)
330 layout.setContentsMargins(0, 0, 0, 0)
331 self.enabled = QCheckBox(tr("筆圧をブラシサイズに反映"))
332 self.enabled.setToolTip(
333 tr("筆圧はブラシサイズだけに反映されます。"
334 "描画不透明度・色・アルファ値には一切影響しません。")
335 )
336 layout.addWidget(self.enabled)
338 form = QFormLayout()
339 self.minimum = QSlider(Qt.Orientation.Horizontal)
340 self.minimum.setRange(1, 100)
341 self.minimum_label = QLabel()
342 self.maximum = QSlider(Qt.Orientation.Horizontal)
343 self.maximum.setRange(10, 300)
344 self.maximum_label = QLabel()
345 for title, slider, label in (
346 (tr("最小サイズ"), self.minimum, self.minimum_label),
347 (tr("最大倍率"), self.maximum, self.maximum_label),
348 ):
349 label.setMinimumWidth(40)
350 row = QWidget()
351 row_layout = QHBoxLayout(row)
352 row_layout.setContentsMargins(0, 0, 0, 0)
353 row_layout.addWidget(slider, 1)
354 row_layout.addWidget(label)
355 slider.valueChanged.connect(lambda v, lab=label: lab.setText(f"{v}%"))
356 form.addRow(title, row)
357 layout.addLayout(form)
358 layout.addWidget(QLabel(tr("筆圧カーブ")))
359 self.curve = PressureCurveWidget()
360 layout.addWidget(self.curve, 1)
362 self.set_settings(settings or {})
363 self.enabled.toggled.connect(self._emit_changed)
364 self.minimum.valueChanged.connect(self._emit_changed)
365 self.maximum.valueChanged.connect(self._emit_changed)
366 self.curve.curveChanged.connect(self._emit_changed)
368 def _emit_changed(self, *_args):
369 self.changed.emit()
371 def settings(self):
372 return {
373 "enabled": self.enabled.isChecked(),
374 "minimum": self.minimum.value() / 100.0,
375 "maximum": self.maximum.value() / 100.0,
376 "points": self.curve.points(),
377 }
379 def set_settings(self, settings):
380 widgets = (self.enabled, self.minimum, self.maximum, self.curve)
381 for widget in widgets:
382 widget.blockSignals(True)
383 try:
384 self.enabled.setChecked(bool(settings.get("enabled", True)))
385 minimum = int(round(float(settings.get("minimum", 0.1)) * 100))
386 maximum = int(round(float(settings.get("maximum", 1.0)) * 100))
387 self.minimum.setValue(max(1, min(100, minimum)))
388 self.maximum.setValue(max(10, min(300, maximum)))
389 self.curve.set_points(settings.get("points") or 1.0)
390 finally:
391 for widget in widgets:
392 widget.blockSignals(False)
393 self.minimum_label.setText(f"{self.minimum.value()}%")
394 self.maximum_label.setText(f"{self.maximum.value()}%")
397class PressureDialog(QDialog):
398 """The brush's pressure: follow the global preset, or its own setting.
400 ``presets`` is the list of preset names and ``preset_settings`` looks one
401 up, so the dialog can preview the chosen preset while it is selected.
402 """
404 editPresetsRequested = Signal()
406 def __init__(
407 self,
408 uses_global,
409 active_preset,
410 presets,
411 preset_settings,
412 brush_settings,
413 parent=None,
414 ):
415 super().__init__(parent)
416 self.setWindowTitle(tr("ブラシの筆圧"))
417 self.resize(440, 470)
418 self._preset_settings = preset_settings
419 self._brush_settings = dict(brush_settings)
420 layout = QVBoxLayout(self)
422 self.use_global = QRadioButton(tr("全体の設定を使う"))
423 self.use_brush = QRadioButton(tr("このブラシの設定を使う"))
424 self.preset = QComboBox()
425 self.preset.addItems(list(presets))
426 index = self.preset.findText(active_preset)
427 self.preset.setCurrentIndex(max(0, index))
428 edit_presets = QPushButton(tr("プリセットを編集…"))
429 edit_presets.clicked.connect(self._edit_presets)
430 preset_row = QHBoxLayout()
431 preset_row.setContentsMargins(22, 0, 0, 0)
432 preset_row.addWidget(QLabel(tr("プリセット")))
433 preset_row.addWidget(self.preset, 1)
434 preset_row.addWidget(edit_presets)
435 layout.addWidget(self.use_global)
436 layout.addLayout(preset_row)
437 layout.addWidget(self.use_brush)
439 self.editor = PressureEditor(self._brush_settings)
440 self.editor.changed.connect(self._remember_brush_settings)
441 # 全体の設定を使う間は、プリセットの中身を見せるだけなので薄く出す。
442 self._editor_opacity = QGraphicsOpacityEffect(self.editor)
443 self.editor.setGraphicsEffect(self._editor_opacity)
444 layout.addWidget(self.editor, 1)
446 (self.use_global if uses_global else self.use_brush).setChecked(True)
447 self.use_global.toggled.connect(self._sync_mode)
448 self.preset.currentIndexChanged.connect(self._sync_mode)
449 self._sync_mode()
451 buttons = QDialogButtonBox(
452 QDialogButtonBox.StandardButton.Ok
453 | QDialogButtonBox.StandardButton.Cancel
454 )
455 buttons.accepted.connect(self.accept)
456 buttons.rejected.connect(self.reject)
457 layout.addWidget(buttons)
459 def _remember_brush_settings(self):
460 if self.use_brush.isChecked(): 460 ↛ exitline 460 didn't return from function '_remember_brush_settings' because the condition on line 460 was always true
461 self._brush_settings = self.editor.settings()
463 def _sync_mode(self, *_args):
464 """Global: show the chosen preset read-only. Brush: edit its own."""
465 uses_global = self.use_global.isChecked()
466 self.preset.setEnabled(uses_global)
467 self.editor.setEnabled(not uses_global)
468 self._editor_opacity.setOpacity(0.45 if uses_global else 1.0)
469 if uses_global:
470 shown = self._preset_settings(self.preset.currentText())
471 else:
472 shown = self._brush_settings
473 self.editor.set_settings(shown or {})
475 def _edit_presets(self):
476 self.reject()
477 self.editPresetsRequested.emit()
479 def uses_global(self):
480 return self.use_global.isChecked()
482 def active_preset(self):
483 return self.preset.currentText()
485 def brush_settings(self):
486 return dict(self._brush_settings)