Coverage for paintmaskanimator/pressure.py: 74%

324 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1import math 

2 

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) 

20 

21from .i18n import tr 

22from .logging_setup import get_logger 

23 

24log = get_logger(__name__) 

25 

26 

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

41 

42 if len(ordered) < 2: 

43 ordered = [(0.0, 0.0), (1.0, 1.0)] 

44 

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 ] 

54 

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 ) 

74 

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 

87 

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 

111 

112 

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 ) 

121 

122 

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

137 

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

150 

151 

152class PressureCurveWidget(QWidget): 

153 """アンカーポイントを追加・移動・削除できる筆圧ベジエカーブ。""" 

154 curveChanged = Signal(object) 

155 

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 ) 

166 

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 

188 

189 def points(self): 

190 return [[float(x), float(y)] for x, y in self._points] 

191 

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

197 

198 def exponent(self): 

199 # 旧形式との互換用。保存・描画では points() を使用する。 

200 return 1.0 

201 

202 def _graph_rect(self): 

203 return self.rect().adjusted(28, 12, -12, -24) 

204 

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

210 

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 ) 

217 

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 

230 

231 def _emit_changed(self): 

232 self.curveChanged.emit(self.points()) 

233 self.update() 

234 

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) 

260 

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) 

278 

279 def mouseReleaseEvent(self, event): 

280 if event.button() == Qt.MouseButton.LeftButton: 

281 self._active_index = None 

282 super().mouseReleaseEvent(event) 

283 

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

317 

318 

319class PressureEditor(QWidget): 

320 """On/off, minimum, maximum and curve of one pressure setting. 

321 

322 ``changed`` fires on every user edit; :meth:`set_settings` does not fire it. 

323 """ 

324 

325 changed = Signal() 

326 

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) 

337 

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) 

361 

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) 

367 

368 def _emit_changed(self, *_args): 

369 self.changed.emit() 

370 

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 } 

378 

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()}%") 

395 

396 

397class PressureDialog(QDialog): 

398 """The brush's pressure: follow the global preset, or its own setting. 

399 

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 """ 

403 

404 editPresetsRequested = Signal() 

405 

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) 

421 

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) 

438 

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) 

445 

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

450 

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) 

458 

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

462 

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 {}) 

474 

475 def _edit_presets(self): 

476 self.reject() 

477 self.editPresetsRequested.emit() 

478 

479 def uses_global(self): 

480 return self.use_global.isChecked() 

481 

482 def active_preset(self): 

483 return self.preset.currentText() 

484 

485 def brush_settings(self): 

486 return dict(self._brush_settings)