Coverage for paintmaskanimator/canvas.py: 72%

1212 statements  

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

1"""The ``PaintCanvas`` widget — the drawing surface and its irreducible core. 

2 

3Architecture (see also the domain modules ``document``/``imaging``/``geometry``/ 

4``colors``/``color_ops``/``project_io``): cohesive method clusters were extracted 

5into ``canvas_<topic>.py`` as ``*Mixin`` classes and composed onto ``PaintCanvas`` 

6below. What remains in this file is the irreducible widget core — ``__init__``, 

7``paintEvent``, the document-backed properties (``frames``/``current_frame``/ 

8``active_layer_index``), view transforms (zoom/pan/canvas<->widget mapping), 

9colour resolution for painting, layer compositing, and the delegating 

10class-method surface that keeps ``imaging``/``color_reduction`` reachable through 

11``PaintCanvas``. Timeline structure editing, the pixel-writing tools and the 

12free-transform geometry live in ``canvas_timeline_ops.py``, 

13``canvas_paint_tools.py`` and ``canvas_transform_geometry.py``. 

14 

15MRO note: ``InputEventMixin`` provides Qt event overrides (``mousePressEvent`` &c.) 

16and therefore MUST precede ``QWidget`` in the base list, or the overrides lose to 

17``QWidget``'s defaults. The reverse hazard applies to *non*-virtual Qt names: a 

18plain method on a mixin that shares its name with a ``QWidget`` method (e.g. 

19``connect``, ``scroll``) is shadowed by Qt's own during attribute lookup, so a 

20mixin must never reuse one. Type-only member declarations for attributes set in 

21``__init__`` (invisible to a checker inspecting one mixin in isolation) live in 

22``_canvas_members.py``; a genuinely undefined name still surfaces there. 

23""" 

24import dataclasses 

25import math 

26import numpy as np 

27import time 

28from typing import Any 

29from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, QTimer, Qt, Signal 

30from PySide6.QtGui import ( 

31 QColor, 

32 QCursor, 

33 QImage, 

34 QPainter, 

35 QPainterPath, 

36 QPen, 

37 QPixmap, 

38 QPolygonF, 

39 QRegion, 

40) 

41from PySide6.QtWidgets import QPushButton, QWidget 

42from .i18n import tr 

43from .constants import OUTSIDE_MARGIN 

44from . import constants 

45from . import (color_ops, colors, display_shrink, geometry, imaging, native, 

46 preferences, view_preview) 

47from .document import Document 

48from .brush_raster import stamp_outline 

49from .toolpanel import tool_label 

50from .utils import blank_image, magnifier_cursor, tool_cursor, workspace_size 

51from .progress import close_counter, create_counter, update_counter 

52from .logging_setup import get_logger 

53from .canvas_brush_stabilizer import BrushStabilizerMixin 

54from .canvas_image_import import ImageImportMixin 

55from .canvas_input_events import InputEventMixin 

56from .canvas_key_frame import KeyFrameMixin 

57from .canvas_onion_interaction import OnionInteractionMixin 

58from .canvas_onion_render import OnionRenderMixin 

59from .canvas_paint_tools import PaintToolsMixin 

60from .canvas_skewer_fill import SkewerFillMixin 

61from .canvas_playback import PlaybackMixin 

62from .canvas_selection import SelectionMixin 

63from .canvas_stroke_display import StrokeDisplayMixin 

64from .canvas_timeline_ops import TimelineStructureMixin 

65from .canvas_transform_geometry import TransformGeometryMixin 

66from .canvas_transform_mask import TransformMaskMixin 

67from .canvas_undo import UndoMixin 

68from .undo_entries import ( 

69 TransformUndo, 

70 DocUndo, 

71 ExposureUndo, 

72 InitialStateUndo, 

73 LayerBatchUndo, 

74 LayerInsertUndo, 

75 LayerRegionUndo, 

76 LayerRemoveUndo, 

77 LayerTilesUndo, 

78 LayerUndo, 

79 PaletteStateUndo, 

80 ScopedBatchUndo, 

81 TweenBatchUndo, 

82) 

83 

84log = get_logger(__name__) 

85 

86 

87 

88#: 色索引・表示フィルタのキャッシュが保持する画素数の上限。10000×10000 の 

89#: レイヤーは1枚で数百MBになるため、件数だけでなく画素数でも制限する 

90#: (最低1件は保持する)。 

91_CACHE_PIXEL_BUDGET = 120_000_000 

92 

93 

94def _bounded_cache_put(cache, key, value, max_entries): 

95 """key[1], key[2] を幅・高さとして、件数と画素数の上限内でキャッシュする。""" 

96 pixels = int(key[1]) * int(key[2]) 

97 total = sum(int(k[1]) * int(k[2]) for k in cache) 

98 while cache and ( 98 ↛ 101line 98 didn't jump to line 101 because the condition on line 98 was never true

99 len(cache) >= max_entries or total + pixels > _CACHE_PIXEL_BUDGET 

100 ): 

101 oldest = next(iter(cache)) 

102 total -= int(oldest[1]) * int(oldest[2]) 

103 cache.pop(oldest) 

104 cache[key] = value 

105 

106class PaintCanvas( 

107 ImageImportMixin, OnionInteractionMixin, OnionRenderMixin, SelectionMixin, 

108 TransformMaskMixin, TransformGeometryMixin, SkewerFillMixin, PaintToolsMixin, 

109 TimelineStructureMixin, BrushStabilizerMixin, StrokeDisplayMixin, 

110 InputEventMixin, UndoMixin, PlaybackMixin, 

111 KeyFrameMixin, QWidget 

112): 

113 status_message=Signal(str) 

114 colorUsed=Signal(QColor) 

115 viewChanged=Signal(float, float) 

116 onionInteractionChanged=Signal() 

117 onionInteractionFinished=Signal() 

118 changed=Signal(); selectionChanged=Signal(); selectionCleared=Signal(); cellChanged=Signal(int,int); imagesDropped=Signal(object); projectDropped=Signal(str); timeRemapDropped=Signal(str); clipAnimationDropped=Signal(str); colorSampled=Signal(QColor) 

119 def __init__(self): 

120 super().__init__(); self.setFocusPolicy(Qt.FocusPolicy.StrongFocus); self.setMouseTracking(True); self.setTabletTracking(True); self.setMinimumSize(320,120); self.setAcceptDrops(True) 

121 self._document=Document() 

122 self._playback_active=False 

123 self._playback_frame_cache={} 

124 self._playback_cache_limit=14 

125 self._playback_resolved_keys=[] 

126 # 再生する尺。終了は排他的。None は文書の現在の長さを使う。 

127 self.playback_range_start = 0 

128 self.playback_range_end: int | None = None 

129 # 連番読込直後の画像を、タイムリマップ再配置用に保持する。 

130 self._sequence_source_bank=[] 

131 self._sequence_source_bank_layer_index=-1 

132 self._sequence_source_bank_layer_name="" 

133 self.clip_studio_source_metadata: dict | None = None 

134 # シートから外した絵番号を、右クリックで再配置するため保持する。 

135 self._sequence_archive={} 

136 self.timeline_mode="sheet" 

137 self.tool="brush"; self.temp_tool=None; self.main_color=QColor("black"); self.sub_color=QColor(255,0,0); self.color_mode="main"; self.transparent_display_color=QColor("white"); self.background_mask_rgb=(255,255,255); self.mask_color_rgbs=set(); self.mask_color_rgb=None; self.mask_all_enabled=True; self.selected_used_color_rgbs=set(); self.visible_color_rgbs=None 

138 # 使用色の親子プレビュー:{子rgb: 親rgb}。表示時のみ子を親色へ塗り替える(非破壊)。 

139 self.preview_color_remap={} 

140 self.selection_polygon=[] 

141 self.selection_mask_override=None 

142 self.selection_outline_polygons=[] 

143 self.selection_mask_rect=None 

144 self._selection_fade_opacity=1.0 

145 self._selection_fade_started=time.monotonic() 

146 self._selection_blink_timer=QTimer(self) 

147 self._selection_blink_timer.setInterval(40) 

148 self._selection_blink_timer.timeout.connect( 

149 self._update_selection_fade 

150 ) 

151 self._selection_blink_timer.start() 

152 self.rect_start=None 

153 self.rect_end=None 

154 self.transform_active=False 

155 self.transform_mode=None 

156 self.transform_original_layer=None 

157 self.transform_source=None 

158 self.transform_source_rect=None 

159 self.transform_points=[] 

160 self.transform_handle=-1 

161 self.transform_drag_kind=None 

162 self.transform_drag_start=QPointF() 

163 self.transform_drag_points=[] 

164 self.transform_original_has_content=False 

165 self.transform_frame_index=0 

166 self.transform_layer_index=0 

167 self.transform_apply_all_frames=False 

168 self.tween_pending = None 

169 self.transform_quality=True 

170 self.transform_quality_active=False 

171 self.transform_line_threshold=96 

172 self.transform_tp_line_colors=() 

173 # TP_mask v0.7 compatible data. The selected image is reduced to exact 

174 # colors and stored as one label id per pixel; the deformation gives 

175 # each output pixel to whichever color covers most of it, so no 

176 # interpolation colors are ever produced. 

177 self.transform_tp_palette=[] 

178 self.transform_tp_masks=[] 

179 self.transform_tp_line_masks=[] 

180 self.transform_tp_prepared_preview=None 

181 self._tp_label_image=None 

182 self._tp_label_colors=None 

183 self._tp_geometry_cache_key=None 

184 self._tp_geometry_cache_bbox=None 

185 self._tp_geometry_cache_fill_overlay=None 

186 self._tp_geometry_cache_line_soft=[] 

187 self._tp_preview_cache_key=None 

188 self._tp_preview_cache_image=None 

189 self._tp_mask_source_key=None 

190 self._tp_proxy_source_key=None 

191 self._tp_proxy_source_image=None 

192 self._tp_proxy_rendering=False 

193 # V62: quality preview is generated outside paintEvent. While the 

194 # per-color TP masks are transformed, a counter popup reports progress. 

195 self._tp_preview_progress_busy=False 

196 self._tp_preview_progress_scheduled=False 

197 self._transform_line_adjusting=False 

198 self._transform_line_preview_timer=QTimer(self) 

199 self._transform_line_preview_timer.setSingleShot(True) 

200 self._transform_line_preview_timer.setInterval(120) 

201 self._transform_line_preview_timer.timeout.connect( 

202 self._render_deferred_transform_line_preview 

203 ) 

204 self.transform_mesh_cols=4 

205 self.transform_mesh_rows=4 

206 self.transform_mesh_grid=4 # legacy compatibility 

207 self.transform_mesh_reference_points=[] 

208 self._brush_resize_anchor = None 

209 self.pen_size=8 

210 self.brush_stabilizer_strength = 0 

211 # バケツ・投げ縄塗りの不透明度(0〜1)。ブラシ等は常に100%。 

212 self.fill_opacity = 1.0 

213 self._stabilized_canvas = None 

214 self._last_raw_canvas = None 

215 self._brush_stabilizer_history = [] 

216 self._last_brush_pressure = 1.0 

217 self._pressure_input_history = [] 

218 self._brush_follow_settle = 0.0 

219 self._brush_follow_timer = QTimer(self) 

220 self._brush_follow_timer.setInterval(16) 

221 self._brush_follow_timer.timeout.connect( 

222 self._advance_stabilized_brush 

223 ) 

224 self.pressure_enabled=True; self.pressure_min=.1; self.pressure_max=1.0; self.pressure_curve=1.0; self.pressure_curve_points=[[0.0,0.0],[0.5,0.5],[1.0,1.0]] 

225 self.zoom=1.0; self.pan=QPointF(); self.rotation=0.0; self.flip_horizontal=False; self.flip_vertical=False; self.onion_skin=False 

226 self.onion_previous_count=1 

227 self.onion_next_count=1 

228 self.onion_previous_opacity=0.22 

229 self.onion_next_opacity=0.22 

230 self.onion_previous_color=QColor(255, 92, 92) 

231 self.onion_next_color=QColor(92, 160, 255) 

232 self.onion_previous_color_enabled=False 

233 self.onion_next_color_enabled=False 

234 self.onion_selected_colors_only=False 

235 self.onion_previous_shift_x=0.0 

236 self.onion_previous_shift_y=0.0 

237 self.onion_previous_rotation=0.0 

238 self.onion_previous_scale=100.0 

239 self.onion_next_shift_x=0.0 

240 self.onion_next_shift_y=0.0 

241 self.onion_next_rotation=0.0 

242 self.onion_next_scale=100.0 

243 # TU/TB用のカメラ倍率。表示用デジタルズームself.zoomとは分離。 

244 self.onion_tu_tb_scale=1.0 

245 self.onion_previous_levels=[100] 

246 self.onion_next_levels=[100] 

247 self.onion_center_percent=50.0 

248 self._onion_interaction_mode=None 

249 self._onion_interaction_direction=-1 

250 self._onion_interaction_axis=None 

251 self._onion_interaction_operation=None 

252 self._onion_interaction_dragging=False 

253 self._onion_interaction_start_widget=QPointF() 

254 self._onion_interaction_start_pan=QPointF() 

255 self._onion_interaction_start_view_rotation=0.0 

256 self._onion_interaction_start_previous=(0.0, 0.0) 

257 self._onion_interaction_start_next=(0.0, 0.0) 

258 self._onion_interaction_start_previous_rotation=0.0 

259 self._onion_interaction_start_next_rotation=0.0 

260 self._onion_interaction_start_previous_scale=100.0 

261 self._onion_interaction_start_next_scale=100.0 

262 self._onion_interaction_start_tu_tb_scale=1.0 

263 self.onion_all_layers=True 

264 self._color_filter_cache = {} 

265 # 画像ごとのRGBインデックスを保持し、表示チェックのたびの再計算を避ける。 

266 self._color_index_cache = {} 

267 self.silhouette_non_background = False 

268 # 比較表示:今の絵の代わりに、保持した色塗り前の初期状態を描く。 

269 self.show_initial_state = False 

270 self._silhouette_cache = {} 

271 # 露光量・チャンネル・隣接色分けの表示プレビュー(画面だけ。書き出しには掛けない)。 

272 self.view_preview = view_preview.ViewPreview(palette=preferences.map_palette()) 

273 self._view_preview_cache = {} 

274 self._view_map_color_mappings = {} 

275 self._onion_cache = {} 

276 self.drawing=False; self.last_canvas=QPointF(); self.last_widget=QPointF(); self.lasso=[]; self.middle_hand=False 

277 self._brush_cursor_widget_pos = QPointF(-1000, -1000) 

278 self._brush_cursor_inside = False 

279 # ブラシ確定時にタイムライン全体を作り直さないための状態。 

280 self._brush_started_with_content = True 

281 self._brush_blended_colors = set() 

282 # Undo用の変更前画素はタイル単位で遅延保存する。全画面サイズの 

283 # QImageをストローク開始時に確保しない。 

284 self._stroke_before_tiles = None 

285 self._stroke_dirty_rect = None 

286 self._stroke_undo_frame = 0 

287 self._stroke_undo_layer = 0 

288 self._stroke_prev_has_content = False 

289 self._brush_runtime_warmed = False 

290 self._cell_structure_dirty = True 

291 # ライン/図形ツールのプレビュー状態 

292 self.line_start=None 

293 self.line_end=None 

294 self.line_control=None 

295 self.line_curve_stage=0 # 0:待機、1:基準線ドラッグ、2:曲率指定 

296 self.shape_start=None 

297 self.shape_end=None 

298 self.undo_stack=[]; self.redo_stack=[]; self.stroke_before=None 

299 # 使用色パネルへの参照。MainWindow が初期化時に差し込む。 

300 # palette_state のUndo/Redoでパネル状態を復元するために使う。 

301 self._palette: Any=None # set by MainWindow to the shared UsedColorPanel 

302 self.mesh_points=[]; self.mesh_original=None; self.mesh_active=-1; self.mesh_grid=4 

303 self.checker_light=QColor(255,255,255); self.checker_dark=QColor(255,255,255) 

304 

305 self.selection_clear_overlay = QPushButton(tr("選択解除"), self) 

306 self.selection_clear_overlay.setToolTip(tr("現在の選択範囲を解除します。")) 

307 self.selection_clear_overlay.setFixedHeight(26) 

308 self.selection_clear_overlay.setStyleSheet( 

309 "QPushButton{background:rgba(35,35,35,215);color:white;" 

310 "border:1px solid white;border-radius:4px;padding:2px 8px;}" 

311 "QPushButton:hover{background:rgba(70,70,70,235);}" 

312 ) 

313 self.selection_clear_overlay.setFocusPolicy( 

314 Qt.FocusPolicy.NoFocus 

315 ) 

316 self.selection_clear_overlay.setAttribute( 

317 Qt.WidgetAttribute.WA_TransparentForMouseEvents, 

318 False, 

319 ) 

320 self.selection_clear_overlay.clicked.connect( 

321 self.clear_selection 

322 ) 

323 self.selection_clear_overlay.hide() 

324 self.selectionChanged.connect( 

325 self._update_selection_clear_overlay 

326 ) 

327 self.changed.connect(self._invalidate_exposure_index) 

328 QTimer.singleShot(0, self.warm_up_brush_runtime) 

329 def _invalidate_exposure_index(self): 

330 self._exposure_index_signature = None 

331 

332 def _position_selection_clear_overlay(self): 

333 button = getattr(self, "selection_clear_overlay", None) 

334 if button is None: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 return 

336 button.adjustSize() 

337 margin = 10 

338 button.move( 

339 max(margin, self.width() - button.width() - margin), 

340 margin, 

341 ) 

342 button.raise_() 

343 

344 def _update_selection_clear_overlay(self): 

345 """選択中は、使用ツールに関係なく右上の解除を有効化する。""" 

346 button = getattr( 

347 self, 

348 "selection_clear_overlay", 

349 None, 

350 ) 

351 if button is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 return 

353 visible = bool(self.selection_polygon) 

354 button.setEnabled(True) 

355 button.setVisible(visible) 

356 if visible: 

357 self._position_selection_clear_overlay() 

358 button.raise_() 

359 

360 def resizeEvent(self, event): 

361 self._position_selection_clear_overlay() 

362 super().resizeEvent(event) 

363 

364 # Core document state lives in self._document; these properties delegate so 

365 # the many existing `self.frames` / `self.current_frame` call sites keep 

366 # working unchanged. 

367 @property 

368 def frames(self): return self._document.frames 

369 @frames.setter 

370 def frames(self, value): 

371 self._document.frames = value 

372 self._invalidate_exposure_index() 

373 @property 

374 def current_frame(self): return self._document.current_frame 

375 @current_frame.setter 

376 def current_frame(self, value): self._document.current_frame = value 

377 @property 

378 def active_layer_index(self): return self._document.active_layer_index 

379 @active_layer_index.setter 

380 def active_layer_index(self, value): self._document.active_layer_index = value 

381 @property 

382 def layers(self): return self._document.layers 

383 @property 

384 def active_layer(self): return self._document.active_layer 

385 def current_state_for(self, entry): 

386 """Capture the inverse of ``entry``: the state undoing it would replace. 

387 

388 Returns ``None`` when the entry targets a cell that no longer exists 

389 (timeline deletions leave stale history behind), telling the caller to 

390 drop it instead of applying it. 

391 """ 

392 def _cell_exists(fi, li): 

393 return ( 

394 0 <= fi < len(self.frames) 

395 and 0 <= li < len(self.frames[fi].layers) 

396 ) 

397 

398 if isinstance(entry, TransformUndo): 

399 return TransformUndo.capture(self) if self.transform_active else None 

400 if isinstance(entry, ExposureUndo): 

401 return ExposureUndo.capture(self, entry.layer, entry.column, 

402 tuple((dst, src) for src, dst in entry.moves)) 

403 if isinstance(entry, DocUndo): 

404 return DocUndo(self.document_snapshot()) 

405 if isinstance(entry, InitialStateUndo): 

406 cells = [ 

407 (fi, li, QImage(image) if (image := self.frames[fi].layers[li].initial_image) is not None else None) 

408 for fi, li, _ in entry.cells if _cell_exists(fi, li) 

409 ] 

410 archived = [ 

411 (li, number, QImage(image) if (image := self._sequence_archive[(li, number)].initial_image) is not None else None) 

412 for li, number, _ in entry.archived if (li, number) in self._sequence_archive 

413 ] 

414 return InitialStateUndo(cells, archived) if cells or archived else None 

415 if isinstance(entry, PaletteStateUndo): 

416 palette = getattr(self, "_palette", None) 

417 if palette is None: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 return None 

419 # 反対側スタックには「現在のパレット状態」を積む。ラベルは対称。 

420 return PaletteStateUndo(palette.capture_history_state(), entry.label) 

421 if isinstance(entry, LayerRegionUndo): 

422 if not _cell_exists(entry.frame, entry.layer): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 return None 

424 layer = self.frames[entry.frame].layers[entry.layer] 

425 return LayerRegionUndo( 

426 entry.frame, 

427 entry.layer, 

428 QRect(entry.rect), 

429 layer.image.copy(entry.rect), 

430 bool(layer.has_content), 

431 ) 

432 if isinstance(entry, LayerTilesUndo): 

433 if not _cell_exists(entry.frame, entry.layer): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 return None 

435 layer = self.frames[entry.frame].layers[entry.layer] 

436 return LayerTilesUndo( 

437 entry.frame, 

438 entry.layer, 

439 tuple( 

440 (QRect(rect), layer.image.copy(QRect(rect))) 

441 for rect, _image in entry.tiles 

442 ), 

443 bool(layer.has_content), 

444 ) 

445 if isinstance(entry, ScopedBatchUndo): 

446 current = [ 

447 (fi, li, QImage(self.frames[fi].layers[li].image), 

448 bool(self.frames[fi].layers[li].has_content)) 

449 for fi, li, _img, _hc in entry.cells 

450 if _cell_exists(fi, li) 

451 ] 

452 return ScopedBatchUndo(current, entry.label) if current else None 

453 if isinstance(entry, LayerBatchUndo): 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 li = entry.layer 

455 current = [ 

456 (fi, QImage(self.frames[fi].layers[li].image), 

457 self.frames[fi].layers[li].has_content) 

458 for fi, _img, _hc in entry.cells 

459 if _cell_exists(fi, li) 

460 ] 

461 return LayerBatchUndo(li, current) if current else None 

462 if isinstance(entry, LayerRemoveUndo): 

463 index = entry.index 

464 stored = [ 

465 frame.layers[index].clone() 

466 for frame in self.frames 

467 if 0 <= index < len(frame.layers) 

468 ] 

469 return LayerInsertUndo( 

470 int(index), 

471 stored, 

472 int(self.active_layer_index), 

473 ) 

474 if isinstance(entry, LayerInsertUndo): 

475 return LayerRemoveUndo( 

476 int(entry.index), 

477 int(self.active_layer_index), 

478 ) 

479 if isinstance(entry, TweenBatchUndo): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 li = entry.layer 

481 current = [] 

482 for fi in range(int(entry.start), min(int(entry.end) + 1, len(self.frames))): 

483 if 0 <= li < len(self.frames[fi].layers): 

484 layer = self.frames[fi].layers[li] 

485 current.append(( 

486 fi, 

487 QImage(layer.image), 

488 bool(layer.has_content), 

489 int(layer.exposure), 

490 )) 

491 return TweenBatchUndo( 

492 int(li), 

493 len(self.frames), 

494 int(entry.start), 

495 int(entry.end), 

496 current, 

497 ) 

498 if isinstance(entry, LayerUndo): 498 ↛ 508line 498 didn't jump to line 508 because the condition on line 498 was always true

499 if not _cell_exists(entry.frame, entry.layer): 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true

500 return None 

501 layer = self.frames[entry.frame].layers[entry.layer] 

502 return LayerUndo( 

503 entry.frame, 

504 entry.layer, 

505 QImage(layer.image), 

506 layer.has_content, 

507 ) 

508 log.warning("unknown undo entry type: %r", type(entry).__name__) 

509 return None 

510 

511 def set_tool(self, t): 

512 if t != self.tool or t == "shape": 

513 self.abort_skewer_fill() 

514 self._stop_brush_follow_timer() 

515 self.line_start = None 

516 self.line_end = None 

517 self.line_control = None 

518 self.line_curve_stage = 0 

519 self.shape_start = None 

520 self.shape_end = None 

521 self.drawing = False 

522 self._brush_line_anchor = None 

523 self.tool = t 

524 self._brush_shift_line = False 

525 self.update_tool_cursor() 

526 self.update() 

527 

528 def _eyedropper_cursor(self): 

529 pixmap = QPixmap(24, 24) 

530 pixmap.fill(Qt.GlobalColor.transparent) 

531 painter = QPainter(pixmap) 

532 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

533 pen = QPen(QColor("black"), 2) 

534 painter.setPen(pen) 

535 painter.drawLine(5, 18, 16, 7) 

536 painter.drawLine(8, 21, 19, 10) 

537 painter.drawLine(15, 6, 20, 11) 

538 painter.drawEllipse(QPointF(5, 19), 2, 2) 

539 painter.end() 

540 return QCursor(pixmap, 5, 19) 

541 

542 

543 @staticmethod 

544 def _normalized_angle(angle): 

545 return geometry.normalized_angle(angle) 

546 

547 @staticmethod 

548 def _rotated_vector(x, y, angle_degrees): 

549 return geometry.rotated_vector(x, y, angle_degrees) 

550 

551 

552 def update_tool_cursor(self): 

553 if self._onion_interaction_mode in ( 553 ↛ 558line 553 didn't jump to line 558 because the condition on line 553 was never true

554 "canvas", 

555 "onion_shift", 

556 "onion_transform", 

557 ): 

558 if ( 

559 self._onion_interaction_dragging 

560 and self._onion_interaction_operation == "rotate" 

561 ): 

562 self.setCursor(Qt.CursorShape.SizeHorCursor) 

563 elif self._onion_interaction_mode == "canvas": 

564 self.setCursor( 

565 Qt.CursorShape.ClosedHandCursor 

566 if self._onion_interaction_dragging 

567 else Qt.CursorShape.OpenHandCursor 

568 ) 

569 elif self._onion_interaction_axis == "x": 

570 self.setCursor(Qt.CursorShape.SizeHorCursor) 

571 elif self._onion_interaction_axis == "y": 

572 self.setCursor(Qt.CursorShape.SizeVerCursor) 

573 else: 

574 self.setCursor(Qt.CursorShape.SizeAllCursor) 

575 return 

576 

577 tool = self.effective_tool() 

578 if tool == "hand": 

579 self.setCursor( 

580 Qt.CursorShape.ClosedHandCursor 

581 if self.drawing or self.middle_hand 

582 else Qt.CursorShape.OpenHandCursor 

583 ) 

584 elif tool == "zoom": 

585 self.setCursor(magnifier_cursor()) 

586 elif tool == "rotate": 

587 self.setCursor(Qt.CursorShape.SizeAllCursor) 

588 elif tool == "eyedropper": 

589 self.setCursor(self._eyedropper_cursor()) 

590 elif tool == "brush" and self._brush_cursor_inside: 

591 # 同じBlankCursorをマウス移動のたびに再設定すると、Windowsで 

592 # OSカーソルと描画リングが一瞬切り替わって見えることがある。 

593 if self.cursor().shape() != Qt.CursorShape.BlankCursor: 

594 self.setCursor(Qt.CursorShape.BlankCursor) 

595 else: 

596 shaped = tool_cursor(tool) 

597 if shaped is not None: 

598 self.setCursor(shaped) 

599 else: 

600 self.unsetCursor() 

601 

602 def set_pen_size(self, value): 

603 self.pen_size = max(0.5, float(value)) 

604 self.update() 

605 

606 

607 def effective_tool(self): 

608 tool = self.temp_tool or self.tool 

609 if tool == "brush" and getattr(self, "_brush_shift_line", False): 

610 return "line" 

611 if tool == "shape": 

612 panel = self._active_tool_panel() 

613 if panel is not None and panel.shape_type.currentData() in ("straight", "curve"): 613 ↛ 615line 613 didn't jump to line 615 because the condition on line 613 was always true

614 return "line" 

615 return tool 

616 def work_rect(self): 

617 w,h=workspace_size(); return QRectF(self.pan.x(),self.pan.y(),w*self.zoom,h*self.zoom) 

618 

619 def set_zoom_around_canvas_center(self, zoom): 

620 """Change zoom without moving the canvas center on screen.""" 

621 old_zoom = max(0.0001, float(self.zoom)) 

622 new_zoom = max(0.05, min(16.0, float(zoom))) 

623 width, height = workspace_size() 

624 canvas_center = QPointF(width / 2.0, height / 2.0) 

625 screen_center = self.pan + canvas_center * old_zoom 

626 self.zoom = new_zoom 

627 self.pan = screen_center - canvas_center * new_zoom 

628 

629 def set_zoom_around_widget_point(self, zoom, widget_pos): 

630 """Change zoom keeping the canvas point under ``widget_pos`` in place. 

631 

632 Zooming about the cursor lets the user magnify exactly where they are 

633 looking. Rotation, flip and the camera scale are all applied about 

634 the work rect, which moves rigidly with ``pan``; so zooming about any 

635 point and then shifting ``pan`` by the drift of the anchor is exact. 

636 """ 

637 anchor = QPointF(widget_pos) 

638 canvas_point = self.widget_to_canvas(anchor) 

639 self.set_zoom_around_canvas_center(zoom) 

640 self.pan += anchor - self.canvas_to_widget(canvas_point) 

641 

642 def center_canvas(self): 

643 """Place the workspace center at the center of the canvas widget.""" 

644 width, height = workspace_size() 

645 self.pan = QPointF( 

646 (self.width() - width * self.zoom) / 2.0, 

647 (self.height() - height * self.zoom) / 2.0, 

648 ) 

649 def canvas_rect(self): 

650 return QRectF(self.pan.x()+OUTSIDE_MARGIN*self.zoom,self.pan.y()+OUTSIDE_MARGIN*self.zoom,constants.CANVAS_WIDTH*self.zoom,constants.CANVAS_HEIGHT*self.zoom) 

651 def widget_to_canvas(self,p): 

652 c=self.work_rect().center(); q=QPointF(p) 

653 if self.rotation: 

654 a=math.radians(-self.rotation); dx=q.x()-c.x();dy=q.y()-c.y();q=QPointF(c.x()+dx*math.cos(a)-dy*math.sin(a),c.y()+dx*math.sin(a)+dy*math.cos(a)) 

655 camera_scale=max(0.0001,float(self.onion_tu_tb_scale)) 

656 if abs(camera_scale-1.0)>1e-9: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

657 q=QPointF( 

658 c.x()+(q.x()-c.x())/camera_scale, 

659 c.y()+(q.y()-c.y())/camera_scale, 

660 ) 

661 x=(q.x()-self.pan.x())/self.zoom; y=(q.y()-self.pan.y())/self.zoom 

662 if self.flip_horizontal:x=workspace_size()[0]-x 

663 if self.flip_vertical:y=workspace_size()[1]-y 

664 return QPointF(x,y) 

665 def canvas_to_widget(self,p): 

666 w,h=workspace_size() 

667 x=w-p.x() if self.flip_horizontal else p.x() 

668 y=h-p.y() if self.flip_vertical else p.y() 

669 q=QPointF(self.pan.x()+x*self.zoom,self.pan.y()+y*self.zoom) 

670 c=self.work_rect().center() 

671 camera_scale=max(0.0001,float(self.onion_tu_tb_scale)) 

672 if abs(camera_scale-1.0)>1e-9: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true

673 q=QPointF( 

674 c.x()+(q.x()-c.x())*camera_scale, 

675 c.y()+(q.y()-c.y())*camera_scale, 

676 ) 

677 if self.rotation: 

678 a=math.radians(self.rotation);dx=q.x()-c.x();dy=q.y()-c.y();q=QPointF(c.x()+dx*math.cos(a)-dy*math.sin(a),c.y()+dx*math.sin(a)+dy*math.cos(a)) 

679 return q 

680 def inside(self,p): w,h=workspace_size(); return 0<=p.x()<w and 0<=p.y()<h 

681 def color(self): 

682 if self.color_mode=="transparent": 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true

683 return QColor(0,0,0,0) 

684 return QColor(self.main_color if self.color_mode=="main" else self.sub_color) 

685 

686 

687 def paint_source_color(self, base_color=None): 

688 """描画元のRGB色を、常にα255で返す。""" 

689 source = QColor( 

690 self.color() if base_color is None else base_color 

691 ) 

692 if source.alpha() == 0: 692 ↛ 694line 692 didn't jump to line 694 because the condition on line 692 was never true

693 # 透明色モードは、キャンバス上では疑似透明色の白。 

694 source = QColor(255, 255, 255) 

695 source.setAlpha(255) 

696 return source 

697 

698 def opaque_paint_color(self, base_color=None): 

699 """選択RGBをα255のまま返す。""" 

700 return self.paint_source_color(base_color) 

701 

702 def _emit_actual_paint_colors(self, colors, maximum=64): 

703 """実際に生成されたRGBを安全に使用色へ通知する。""" 

704 if colors is None: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true

705 return 

706 try: 

707 iterator = iter(colors) 

708 except TypeError: 

709 return 

710 

711 emitted = 0 

712 seen = set() 

713 for value in iterator: 

714 try: 

715 rgb = tuple( 

716 max(0, min(255, int(channel))) 

717 for channel in value[:3] 

718 ) 

719 except ( 

720 TypeError, 

721 ValueError, 

722 IndexError, 

723 ): 

724 continue 

725 if rgb == (255, 255, 255) or rgb in seen: 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true

726 continue 

727 seen.add(rgb) 

728 red, green, blue = ( 

729 int(rgb[0]), 

730 int(rgb[1]), 

731 int(rgb[2]), 

732 ) 

733 self.colorUsed.emit( 

734 QColor(red, green, blue) 

735 ) 

736 emitted += 1 

737 if emitted >= max(1, int(maximum)): 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true

738 break 

739 

740 def _binary_paint_overlay_rgba(self, overlay): 

741 """描画マスクの境界を完全な0/255へ2値化する。 

742 

743 筆圧による線幅変化、曲線、ラインの入り抜きで発生した 

744 半端なアルファ値を、RGB合成の前に除去する。 

745 """ 

746 rgba = self._qimage_rgba_array(overlay) 

747 if rgba.size == 0: 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true

748 return rgba 

749 

750 alpha = rgba[:, :, 3] 

751 active = alpha >= 128 

752 

753 # 非描画部分は完全な透明、描画部分は完全な不透明に固定する。 

754 rgba[~active, :3] = 0 

755 rgba[:, :, 3][~active] = 0 

756 rgba[:, :, 3][active] = 255 

757 return rgba 

758 

759 @staticmethod 

760 def _paint_rgb_palette(*args, **kwargs): 

761 return colors.paint_rgb_palette(*args, **kwargs) 

762 

763 @classmethod 

764 def _snap_overlay_to_exact_rgbs(cls, *args, **kwargs): 

765 return color_ops._snap_overlay_to_exact_rgbs(*args, **kwargs) 

766 

767 def _blend_overlay_into_active_layer( 

768 self, 

769 overlay, 

770 top_left=None, 

771 exact_colors=None, 

772 opacity=1.0, 

773 ): 

774 """選択RGBをそのままレイヤーへ直書きする。 

775 

776 白(#FFFFFF)は本物の消しゴムとして該当画素を alpha=0 へ抜く。 

777 ``opacity`` が 1 未満のとき(バケツ・投げ縄塗りの不透明度)だけ、 

778 下地RGB(透明は白とみなす)と混色し、アルファは 255 のまま書く。 

779 """ 

780 if overlay is None or overlay.isNull(): 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true

781 return () 

782 

783 destination_image = self.active_layer.image 

784 if ( 784 ↛ 788line 784 didn't jump to line 788 because the condition on line 784 was never true

785 destination_image is None 

786 or destination_image.isNull() 

787 ): 

788 return () 

789 

790 point = ( 

791 top_left 

792 if top_left is not None 

793 else QPoint(0, 0) 

794 ) 

795 origin_x = int(point.x()) 

796 origin_y = int(point.y()) 

797 

798 source_x = max(0, -origin_x) 

799 source_y = max(0, -origin_y) 

800 destination_x = max(0, origin_x) 

801 destination_y = max(0, origin_y) 

802 width = min( 

803 overlay.width() - source_x, 

804 destination_image.width() - destination_x, 

805 ) 

806 height = min( 

807 overlay.height() - source_y, 

808 destination_image.height() - destination_y, 

809 ) 

810 if width <= 0 or height <= 0: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true

811 return () 

812 

813 overlay_rgba = self._binary_paint_overlay_rgba( 

814 overlay 

815 ) 

816 overlay_rgba = overlay_rgba[ 

817 source_y:source_y + height, 

818 source_x:source_x + width, 

819 ] 

820 active_mask = ( 

821 overlay_rgba[:, :, 3] == 255 

822 ) 

823 if not np.any(active_mask): 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true

824 return () 

825 

826 # プレマルチプライ変換の±1誤差を、正規RGBへ必ず吸着する。 

827 overlay_rgba = self._snap_overlay_to_exact_rgbs( 

828 overlay_rgba, 

829 active_mask, 

830 exact_colors, 

831 ) 

832 

833 amount = max(0.0, min(1.0, float(opacity))) 

834 result_rgba = np.zeros_like(overlay_rgba) 

835 # 100%は下地を参照せず正規RGBを直書きする(近似色を生成しない)。 

836 result_rgba[active_mask, :3] = overlay_rgba[active_mask, :3] 

837 result_rgba[active_mask, 3] = 255 

838 blended = amount < 0.999999 

839 if blended: 

840 source_white = active_mask & np.all( 

841 overlay_rgba[:, :, :3] == 255, axis=2 

842 ) 

843 blend_mask = active_mask & ~source_white 

844 base_rgba = self._qimage_rgba_array( 

845 destination_image.copy( 

846 destination_x, destination_y, width, height 

847 ) 

848 ) 

849 base_rgb = base_rgba[:, :, :3].astype(np.float32) 

850 base_rgb[base_rgba[:, :, 3] == 0] = 255.0 

851 mixed = np.clip( 

852 np.rint( 

853 base_rgb * (1.0 - amount) 

854 + overlay_rgba[:, :, :3].astype(np.float32) * amount 

855 ), 

856 0, 

857 255, 

858 ).astype(np.uint8) 

859 result_rgba[blend_mask, :3] = mixed[blend_mask] 

860 # 混色の結果がたまたま白になった画素は消しゴム扱いにしない。 

861 accidental_white = blend_mask & np.all( 

862 result_rgba[:, :, :3] == 255, axis=2 

863 ) 

864 result_rgba[accidental_white, :3] = 254 

865 written_rgb = result_rgba[active_mask, :3] 

866 

867 # 白(#FFFFFF)ストロークは疑似透明ではなく本物の消しゴムとして扱う。 

868 # 該当画素をレイヤーへ書かず、alpha=0 へ抜く(下のレイヤーが透ける)。 

869 erase_mask = active_mask & np.all( 

870 result_rgba[:, :, :3] == 255, 

871 axis=2, 

872 ) 

873 erase_rgba = None 

874 if np.any(erase_mask): 

875 erase_rgba = np.zeros_like(result_rgba) 

876 erase_rgba[erase_mask, 3] = 255 

877 # SourceOver では白を書き込まない(消去はこの後 DestinationOut で行う)。 

878 result_rgba[erase_mask, 3] = 0 

879 

880 result_image = self._rgba_array_to_qimage( 

881 result_rgba 

882 ) 

883 painter = QPainter(destination_image) 

884 painter.setCompositionMode( 

885 QPainter.CompositionMode.CompositionMode_SourceOver 

886 ) 

887 painter.drawImage( 

888 QPoint(destination_x, destination_y), 

889 result_image, 

890 ) 

891 if erase_rgba is not None: 

892 erase_image = self._rgba_array_to_qimage(erase_rgba) 

893 painter.setCompositionMode( 

894 QPainter.CompositionMode.CompositionMode_DestinationOut 

895 ) 

896 painter.drawImage( 

897 QPoint(destination_x, destination_y), 

898 erase_image, 

899 ) 

900 painter.end() 

901 

902 if written_rgb.size == 0: 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true

903 return () 

904 

905 # 正規RGBを通知する。白は消しゴム扱いなので使用色に含めない。 

906 palette = self._paint_rgb_palette(exact_colors) 

907 if palette.size and not blended: 

908 return tuple( 

909 rgb 

910 for rgb in ( 

911 tuple(int(channel) for channel in np.asarray(row).tolist()) 

912 for row in palette 

913 ) 

914 if rgb != (255, 255, 255) 

915 ) 

916 

917 unique = np.unique( 

918 written_rgb, 

919 axis=0, 

920 ) 

921 return tuple( 

922 rgb 

923 for rgb in ( 

924 tuple(int(channel) for channel in row) 

925 for row in unique 

926 ) 

927 if rgb != (255, 255, 255) 

928 ) 

929 

930 

931 def _ensure_before_region(self, rect): 

932 """Lazily preserve pre-stroke pixels in fixed-size touched tiles.""" 

933 tiles = self._stroke_before_tiles 

934 if tiles is None: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true

935 return 

936 live = self.active_layer.image 

937 bounds = QRect( 

938 0, 0, live.width(), live.height() 

939 ) 

940 rect = QRect(rect).intersected(bounds) 

941 if rect.isEmpty(): 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true

942 return 

943 tile_size = 256 

944 first_x = rect.left() // tile_size 

945 last_x = rect.right() // tile_size 

946 first_y = rect.top() // tile_size 

947 last_y = rect.bottom() // tile_size 

948 for tile_y in range(first_y, last_y + 1): 

949 for tile_x in range(first_x, last_x + 1): 

950 key = (tile_x, tile_y) 

951 if key in tiles: 

952 continue 

953 tile_rect = QRect( 

954 tile_x * tile_size, 

955 tile_y * tile_size, 

956 tile_size, 

957 tile_size, 

958 ).intersected(bounds) 

959 tiles[key] = (tile_rect, live.copy(tile_rect)) 

960 self._stroke_dirty_rect = self._stroke_dirty_rect.united(rect) 

961 if getattr(self, "_stroke_view_preview", None) is not None: 

962 self._stroke_view_dirty = self._stroke_view_dirty.united(rect) 

963 

964 

965 def selected_mask_colors(self): 

966 values = { 

967 tuple(int(channel) for channel in rgb[:3]) 

968 for rgb in getattr(self, "mask_color_rgbs", set()) 

969 if rgb is not None and len(rgb) >= 3 

970 } 

971 legacy = getattr(self, "mask_color_rgb", None) 

972 if legacy is not None and not values: 

973 values.add(tuple(int(channel) for channel in legacy[:3])) 

974 return values 

975 def selected_used_colors(self): 

976 return { 

977 tuple(int(channel) for channel in rgb[:3]) 

978 for rgb in getattr(self, "selected_used_color_rgbs", set()) 

979 if rgb is not None and len(rgb) >= 3 

980 } 

981 

982 

983 def request_quality_preview_counter(self, label=None): 

984 label = tr("クオリティプレビューを生成しています") if label is None else label 

985 """Schedule quality rendering after the current paint/input event.""" 

986 if ( 

987 self._tp_preview_progress_busy 

988 or self._tp_preview_progress_scheduled 

989 or not self.transform_active 

990 or not self.transform_quality_active 

991 ): 

992 return 

993 self._tp_preview_progress_scheduled = True 

994 QTimer.singleShot( 

995 0, 

996 lambda text=str(label): self.refresh_quality_preview_with_counter(text), 

997 ) 

998 

999 def refresh_quality_preview_with_counter(self, label=None, full_resolution=False): 

1000 """Build the expensive TP_mask preview with a visible mask counter.""" 

1001 # Not a default argument: tr() there would run at import time, before the 

1002 # translator is installed. 

1003 label = tr("クオリティプレビューを生成しています") if label is None else label 

1004 self._tp_preview_progress_scheduled = False 

1005 if ( 

1006 self._tp_preview_progress_busy 

1007 or not self.transform_active 

1008 or not self.transform_quality_active 

1009 or self._transform_line_adjusting 

1010 or self.transform_source is None 

1011 or self.transform_source.isNull() 

1012 ): 

1013 return 

1014 

1015 target_width = self.active_layer.image.width() 

1016 target_height = self.active_layer.image.height() 

1017 use_proxy = ( 

1018 not full_resolution 

1019 and self._tp_uses_proxy(target_width, target_height) 

1020 ) 

1021 if use_proxy: 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true

1022 if self._tp_preview_cache_image is not None: 

1023 return 

1024 else: 

1025 geometry_current = self._tp_geometry_cache_is_current( 

1026 self.transform_source, target_width, target_height 

1027 ) 

1028 if geometry_current and self._tp_preview_cache_image is not None: 1028 ↛ 1029line 1028 didn't jump to line 1029 because the condition on line 1028 was never true

1029 return 

1030 

1031 total = max( 

1032 1, 

1033 len(self.transform_tp_masks) 

1034 + len(self.transform_tp_line_masks) 

1035 + 2, 

1036 ) 

1037 window: Any = self.window() 

1038 progress = None 

1039 

1040 # create_counter() calls processEvents(). Mark the renderer as busy 

1041 # first so a repaint during popup creation uses the lightweight preview 

1042 # instead of recursively starting another TP_mask calculation. 

1043 self._tp_preview_progress_busy = True 

1044 try: 

1045 if window is not None: 1045 ↛ 1057line 1045 didn't jump to line 1057 because the condition on line 1045 was always true

1046 progress = create_counter( 

1047 window, 

1048 ( 

1049 tr("Tp_mask 軽量プレビュー") 

1050 if use_proxy 

1051 else tr("Tp_mask クオリティプレビュー") 

1052 ), 

1053 total, 

1054 label, 

1055 ) 

1056 

1057 def report(value, report_total, message): 

1058 if progress is not None: 1058 ↛ exitline 1058 didn't return from function 'report' because the condition on line 1058 was always true

1059 update_counter( 

1060 progress, value, report_total, message 

1061 ) 

1062 

1063 self.transform_preview_image( 

1064 self.transform_source, 

1065 target_width, 

1066 target_height, 

1067 quality=True, 

1068 preview_only=use_proxy, 

1069 progress_callback=report, 

1070 ) 

1071 if progress is not None: 1071 ↛ 1084line 1071 didn't jump to line 1084 because the condition on line 1071 was always true

1072 update_counter( 

1073 progress, 

1074 total, 

1075 total, 

1076 tr("プレビューを更新しました"), 

1077 ) 

1078 except (ValueError, IndexError, TypeError, RuntimeError, AttributeError, MemoryError) as exc: 

1079 log.warning("quality preview generation failed: %s", exc, exc_info=True) 

1080 self.status_message.emit( 

1081 tr("クオリティプレビューを生成できませんでした: {exc}").format(exc=exc) 

1082 ) 

1083 finally: 

1084 if progress is not None: 1084 ↛ 1086line 1084 didn't jump to line 1086 because the condition on line 1084 was always true

1085 close_counter(progress) 

1086 self._tp_preview_progress_busy = False 

1087 self.update() 

1088 

1089 def set_transform_quality(self, enabled): 

1090 self.transform_quality = bool(enabled) 

1091 if self.transform_active: 1091 ↛ 1092line 1091 didn't jump to line 1092 because the condition on line 1091 was never true

1092 self.transform_quality_active = self.transform_quality 

1093 self.transform_tp_line_colors = tuple(sorted(self.selected_used_colors())) 

1094 if self.transform_quality_active: 

1095 target_width = self.active_layer.image.width() 

1096 target_height = self.active_layer.image.height() 

1097 if self._tp_uses_proxy(target_width, target_height): 

1098 self._clear_tp_transform_masks(clear_proxy=False) 

1099 else: 

1100 self._prepare_tp_transform_masks() 

1101 else: 

1102 self._clear_tp_transform_masks() 

1103 self._invalidate_tp_preview_cache() 

1104 if self.transform_active and self.transform_quality_active: 1104 ↛ 1105line 1104 didn't jump to line 1105 because the condition on line 1104 was never true

1105 self.request_quality_preview_counter( 

1106 tr("クオリティ方式へ切り替えています") 

1107 ) 

1108 self.update() 

1109 

1110 def set_transform_line_threshold(self, value): 

1111 self.transform_line_threshold = max(1, min(254, int(value))) 

1112 # v0.7 caches transformed soft masks. A line-width change only 

1113 # reapplies the threshold and does not transform every color again. 

1114 self._invalidate_tp_preview_cache(geometry=False) 

1115 if ( 1115 ↛ 1121line 1115 didn't jump to line 1121 because the condition on line 1115 was never true

1116 self.transform_active 

1117 and self.transform_quality_active 

1118 and not self._tp_preview_progress_busy 

1119 and self._tp_geometry_cache_fill_overlay is not None 

1120 ): 

1121 target_width = self.active_layer.image.width() 

1122 target_height = self.active_layer.image.height() 

1123 self.transform_preview_image( 

1124 self.transform_source, 

1125 target_width, 

1126 target_height, 

1127 quality=True, 

1128 preview_only=self._tp_uses_proxy( 

1129 target_width, target_height 

1130 ), 

1131 ) 

1132 self.update() 

1133 

1134 def begin_transform_line_adjustment(self): 

1135 """スライダー操作中は重いクオリティ再生成を保留する。""" 

1136 self._transform_line_adjusting = True 

1137 self._transform_line_preview_timer.stop() 

1138 self._tp_preview_progress_scheduled = False 

1139 

1140 def finish_transform_line_adjustment(self): 

1141 """スライダーを離した時にクオリティプレビューを一度だけ更新する。""" 

1142 was_adjusting = self._transform_line_adjusting 

1143 self._transform_line_adjusting = False 

1144 if ( 

1145 was_adjusting 

1146 and self.transform_active 

1147 and self.transform_quality_active 

1148 ): 

1149 # マウスリリース処理を先に完了させ、クリックが確定操作の 

1150 # ように見えないよう少し遅らせて一度だけ生成する。 

1151 self._transform_line_preview_timer.start() 

1152 self.update() 

1153 

1154 def _render_deferred_transform_line_preview(self): 

1155 if self.transform_active and self.transform_quality_active: 

1156 self.request_quality_preview_counter( 

1157 tr("実線の太さをプレビューへ反映しています") 

1158 ) 

1159 

1160 def set_transform_line_colors(self, colors): 

1161 normalized = { 

1162 tuple(int(channel) for channel in rgb[:3]) 

1163 for rgb in (colors or set()) 

1164 if rgb is not None and len(rgb) >= 3 

1165 } 

1166 self.selected_used_color_rgbs = normalized 

1167 if self.transform_active: 1167 ↛ 1168line 1167 didn't jump to line 1168 because the condition on line 1167 was never true

1168 self.transform_tp_line_colors = tuple(sorted(normalized)) 

1169 if self.transform_quality_active: 

1170 target_width = self.active_layer.image.width() 

1171 target_height = self.active_layer.image.height() 

1172 self._clear_tp_transform_masks(clear_proxy=False) 

1173 if not self._tp_uses_proxy(target_width, target_height): 

1174 self._prepare_tp_transform_masks() 

1175 self.request_quality_preview_counter( 

1176 tr("実線色を反映しています") 

1177 ) 

1178 else: 

1179 self._invalidate_tp_preview_cache() 

1180 self.update() 

1181 

1182 def apply_selection_clip(self, painter, offset_x=0.0, offset_y=0.0): 

1183 """Apply the active selection without relying on PySide overload-specific arguments.""" 

1184 if not self.selection_polygon: 1184 ↛ 1186line 1184 didn't jump to line 1186 because the condition on line 1184 was always true

1185 return 

1186 if self.selection_mask_override is not None: 

1187 device = painter.device() 

1188 mask = self.selection_mask_bool( 

1189 device.width(), 

1190 device.height(), 

1191 int(round(offset_x)), 

1192 int(round(offset_y)), 

1193 ) 

1194 if mask is None: 

1195 return 

1196 region = QRegion() 

1197 for y in range(mask.shape[0]): 

1198 row = mask[y] # pyright: ignore[reportOptionalSubscript] 

1199 changes = np.diff( 

1200 np.pad(row.astype(np.int8), (1, 1)) 

1201 ) 

1202 starts = np.flatnonzero(changes == 1) 

1203 ends = np.flatnonzero(changes == -1) 

1204 for start, end in zip(starts, ends): 

1205 region += QRegion(int(start), y, int(end - start), 1) 

1206 painter.setClipRegion(region) 

1207 return 

1208 polygon = QPolygonF([ 

1209 QPointF(float(point.x()) - float(offset_x), float(point.y()) - float(offset_y)) 

1210 for point in self.selection_polygon 

1211 ]) 

1212 if polygon.count() >= 3: 

1213 path = QPainterPath() 

1214 path.addPolygon(polygon) 

1215 path.closeSubpath() 

1216 painter.setClipPath(path) 

1217 

1218 @classmethod 

1219 def image_alpha_statistics(cls, *args, **kwargs): 

1220 return color_ops.image_alpha_statistics(*args, **kwargs) 

1221 

1222 @classmethod 

1223 def binarize_alpha_for_pixel_art(cls, *args, **kwargs): 

1224 return color_ops.binarize_alpha_for_pixel_art(*args, **kwargs) 

1225 

1226 @classmethod 

1227 def opaque_rgb_color_count(cls, *args, **kwargs): 

1228 return color_ops.opaque_rgb_color_count(*args, **kwargs) 

1229 

1230 @classmethod 

1231 def detect_opaque_border_background(cls, *args, **kwargs): 

1232 return color_ops.detect_opaque_border_background(*args, **kwargs) 

1233 

1234 @staticmethod 

1235 def _local_color_variation(*args, **kwargs): 

1236 return colors.local_color_variation(*args, **kwargs) 

1237 

1238 @classmethod 

1239 def estimate_mixed_boundary_pixels(cls, *args, **kwargs): 

1240 return color_ops.estimate_mixed_boundary_pixels(*args, **kwargs) 

1241 

1242 @staticmethod 

1243 def _median_cut_palette_from_samples(*args, **kwargs): 

1244 return colors.median_cut_palette_from_samples(*args, **kwargs) 

1245 

1246 @classmethod 

1247 def _surface_guided_palette_labels(cls, *args, **kwargs): 

1248 return color_ops._surface_guided_palette_labels(*args, **kwargs) 

1249 

1250 @staticmethod 

1251 def _line_palette_labels(*args, **kwargs): 

1252 return color_ops._line_palette_labels(*args, **kwargs) 

1253 

1254 

1255 @staticmethod 

1256 def normalize_tone_curve_points(*args, **kwargs): 

1257 return colors.normalize_tone_curve_points(*args, **kwargs) 

1258 

1259 @classmethod 

1260 def tone_curve_samples(cls, *args, **kwargs): 

1261 return color_ops.tone_curve_samples(*args, **kwargs) 

1262 

1263 @classmethod 

1264 def tone_curve_lut(cls, *args, **kwargs): 

1265 return color_ops.tone_curve_lut(*args, **kwargs) 

1266 

1267 @classmethod 

1268 def apply_tone_curve(cls, *args, **kwargs): 

1269 return color_ops.apply_tone_curve(*args, **kwargs) 

1270 

1271 @staticmethod 

1272 def _rgb_hsv_features(*args, **kwargs): 

1273 return colors.rgb_hsv_features(*args, **kwargs) 

1274 

1275 @classmethod 

1276 def _hue_cluster_palette_from_samples(cls, *args, **kwargs): 

1277 return color_ops._hue_cluster_palette_from_samples(*args, **kwargs) 

1278 

1279 @staticmethod 

1280 def _priority_palette_colors_from_samples(*args, **kwargs): 

1281 return colors.priority_palette_colors_from_samples(*args, **kwargs) 

1282 

1283 

1284 @classmethod 

1285 def build_color_reduction_palette(cls, *args, **kwargs): 

1286 return color_ops.build_color_reduction_palette(*args, **kwargs) 

1287 

1288 

1289 @classmethod 

1290 def apply_color_reduction_palette(cls, *args, **kwargs): 

1291 return color_ops.apply_color_reduction_palette(*args, **kwargs) 

1292 

1293 

1294 @staticmethod 

1295 def _qimage_rgba_array(image): 

1296 return imaging.qimage_rgba_array(image) 

1297 

1298 @staticmethod 

1299 def _rgba_array_to_qimage(rgba): 

1300 return imaging.rgba_array_to_qimage(rgba) 

1301 

1302 @staticmethod 

1303 def _pil_l_to_qimage(mask): 

1304 return imaging.pil_l_to_qimage(mask) 

1305 

1306 @staticmethod 

1307 def _qimage_gray_array(image): 

1308 return imaging.qimage_gray_array(image) 

1309 

1310 @classmethod 

1311 def _qimage_to_pil_rgba(cls, image): 

1312 return imaging.qimage_to_pil_rgba(image) 

1313 

1314 @classmethod 

1315 def _pil_rgba_to_qimage(cls, image): 

1316 return imaging.pil_rgba_to_qimage(image) 

1317 

1318 

1319 def sample_color(self, p, include_canvas_background=True): 

1320 x, y = int(p.x()), int(p.y()) 

1321 width, height = workspace_size() 

1322 if not (0 <= x < width and 0 <= y < height): 1322 ↛ 1323line 1322 didn't jump to line 1323 because the condition on line 1322 was never true

1323 return 

1324 

1325 # 上から順に実画像を調べる。レイヤー表示不透明度は色データへ 

1326 # 混ぜず、スポイトでは元のRGBA色を取得する。 

1327 color = None 

1328 layer_count = len(self.frames[self.current_frame].layers) 

1329 for layer_index in range(layer_count - 1, -1, -1): 

1330 key_frame = self.resolve_key_frame( 

1331 self.current_frame, 

1332 layer_index, 

1333 ) 

1334 if key_frame is None: 1334 ↛ 1336line 1334 didn't jump to line 1336 because the condition on line 1334 was always true

1335 continue 

1336 layer = self.frames[key_frame].layers[layer_index] 

1337 if not layer.visible or float(layer.opacity) <= 0.0: 

1338 continue 

1339 source = layer.image 

1340 if not (0 <= x < source.width() and 0 <= y < source.height()): 

1341 continue 

1342 # 使用色の表示OFFなどで見えていない画素は飛ばすが、 

1343 # 表示不透明度やシルエット色はスポイト色へ混ぜない。 

1344 display_source = self._display_layer_image(layer, layer_index) 

1345 if display_source is None: 

1346 continue 

1347 if display_source.pixelColor(x, y).alpha() == 0: 

1348 continue 

1349 candidate = source.pixelColor(x, y) 

1350 if candidate.alpha() > 0: 

1351 color = QColor(candidate) 

1352 break 

1353 

1354 if color is None or color.alpha() == 0: 1354 ↛ 1359line 1354 didn't jump to line 1359 because the condition on line 1354 was always true

1355 if not include_canvas_background: 1355 ↛ 1356line 1355 didn't jump to line 1356 because the condition on line 1355 was never true

1356 return 

1357 # Emit the background sentinel, independent of its view colour. 

1358 color = QColor("white") 

1359 color.setAlpha(255) 

1360 sampled_background = ( 

1361 color.red(), color.green(), color.blue() 

1362 ) == (255, 255, 255) 

1363 if sampled_background: 1363 ↛ 1366line 1363 didn't jump to line 1366 because the condition on line 1363 was always true

1364 changed = self.color_mode != "transparent" 

1365 self.color_mode = "transparent" 

1366 elif self.color_mode == "sub": 

1367 changed = QColor(self.sub_color) != color 

1368 self.sub_color = QColor(color) 

1369 else: 

1370 changed = self.color_mode == "transparent" or QColor(self.main_color) != color 

1371 self.main_color = QColor(color) 

1372 if self.color_mode == "transparent": 

1373 self.color_mode = "main" 

1374 if changed: 1374 ↛ exitline 1374 didn't return from function 'sample_color' because the condition on line 1374 was always true

1375 self.colorSampled.emit(QColor(color)) 

1376 self.update() 

1377 

1378 

1379 def silhouette_layer_image(self, layer, apply_palette_filter=True): 

1380 base = self.filtered_layer_image( 

1381 layer, 

1382 apply_palette_filter, 

1383 ) 

1384 if base is None: 

1385 return QImage() 

1386 if not self.silhouette_non_background: 

1387 return base 

1388 try: 

1389 key = (int(base.cacheKey()), base.width(), base.height()) 

1390 except (AttributeError, RuntimeError, TypeError) as exc: 

1391 log.debug("cacheKey() unavailable, using id() fallback: %s", exc) 

1392 key = (id(base), base.width(), base.height()) 

1393 cached = self._silhouette_cache.get(key) 

1394 if cached is not None: 

1395 return cached 

1396 rgba = base.convertToFormat(QImage.Format.Format_RGBA8888) 

1397 width, height = rgba.width(), rgba.height() 

1398 ptr = imaging.qimage_buffer(rgba) 

1399 rows = np.frombuffer(ptr, dtype=np.uint8).reshape((height, rgba.bytesPerLine())) 

1400 pixels = rows[:, :width * 4].reshape((height, width, 4)) 

1401 visible = pixels[:, :, 3] > 0 

1402 pixels[:, :, 0][visible] = 0 

1403 pixels[:, :, 1][visible] = 0 

1404 pixels[:, :, 2][visible] = 0 

1405 result = rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

1406 if len(self._silhouette_cache) >= 64: 

1407 self._silhouette_cache.clear() 

1408 self._silhouette_cache[key] = result 

1409 return result 

1410 

1411 def _color_index_for_image(self, image): 

1412 """Return the cached RGBA/pixel index used by palette filtering.""" 

1413 try: 

1414 image_key = int(image.cacheKey()) 

1415 except (AttributeError, RuntimeError, TypeError) as exc: 

1416 log.debug("cacheKey() unavailable, using id() fallback: %s", exc) 

1417 image_key = id(image) 

1418 width, height = image.width(), image.height() 

1419 index_key = (image_key, width, height) 

1420 indexed = self._color_index_cache.get(index_key) 

1421 if indexed is not None: 1421 ↛ 1422line 1421 didn't jump to line 1422 because the condition on line 1421 was never true

1422 return indexed 

1423 

1424 if width <= 0 or height <= 0: 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true

1425 return image, None, None 

1426 # ARGB32(非プレマルチプライ)は uint32 で見ると 0xAARRGGBB なので、 

1427 # 0xRRGGBB の索引とアルファの判定がそれぞれ1回の演算で求まる。 

1428 argb = image.convertToFormat(QImage.Format.Format_ARGB32) 

1429 values = np.frombuffer( 

1430 imaging.qimage_buffer(argb), dtype=np.uint32 

1431 ).reshape((height, argb.bytesPerLine() // 4))[:, :width] 

1432 packed = values & np.uint32(0x00FFFFFF) 

1433 opaque = values >= np.uint32(0x01000000) 

1434 # 先頭要素は元画像(暗黙共有なのでコピーは発生しない)。 

1435 indexed = (image, packed, opaque) 

1436 _bounded_cache_put(self._color_index_cache, index_key, indexed, 24) 

1437 return indexed 

1438 

1439 def filtered_layer_image(self, layer, apply_palette_filter=True): 

1440 """Return a cached display-only image honoring this layer's palette visibility.""" 

1441 visible = ( 

1442 getattr(self, "visible_color_rgbs", None) 

1443 if apply_palette_filter else None 

1444 ) 

1445 legacy_rgb = ( 

1446 tuple(int(value) for value in layer.color_filter_rgb[:3]) 

1447 if getattr(layer, "color_filter_enabled", False) and layer.color_filter_rgb is not None 

1448 else None 

1449 ) 

1450 # 使用色の親子プレビュー:表示時のみ子色を親色へ塗り替える(非破壊)。 

1451 remap = ( 

1452 dict(getattr(self, "preview_color_remap", None) or {}) 

1453 if apply_palette_filter else {} 

1454 ) 

1455 if visible is None and legacy_rgb is None and not remap: 

1456 return layer.image 

1457 rgb = legacy_rgb 

1458 try: 

1459 image_key = int(layer.image.cacheKey()) 

1460 except (AttributeError, RuntimeError, TypeError) as exc: 

1461 log.debug("cacheKey() unavailable, using id() fallback: %s", exc) 

1462 image_key = id(layer.image) 

1463 width, height = layer.image.width(), layer.image.height() 

1464 visible_key = None if visible is None else tuple(sorted(visible)) 

1465 remap_key = tuple(sorted(remap.items())) if remap else None 

1466 key = (image_key, width, height, rgb, visible_key, remap_key) 

1467 cached = self._color_filter_cache.get(key) 

1468 if cached is not None: 

1469 return cached 

1470 

1471 if width <= 0 or height <= 0: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true

1472 return layer.image 

1473 rgba = layer.image.convertToFormat(QImage.Format.Format_RGBA8888) 

1474 ptr = imaging.qimage_buffer(rgba) 

1475 rows = np.frombuffer(ptr, dtype=np.uint8).reshape((height, rgba.bytesPerLine())) 

1476 pixels = rows[:, :width * 4].reshape((height, width, 4)) 

1477 

1478 result = native.filter_palette(pixels, visible, rgb, remap) 

1479 filtered = QImage( 

1480 result.data, width, height, width * 4, QImage.Format.Format_RGBA8888 

1481 ).convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

1482 _bounded_cache_put(self._color_filter_cache, key, filtered, 96) 

1483 return filtered 

1484 

1485 def _layer_display_source(self, layer, layer_index): 

1486 """画面に出すレイヤー画像。比較表示のときは色塗り前の初期状態。 

1487 

1488 初期状態を保持していないセルは今の絵をそのまま出す(:mod:`bucket_fill` 

1489 などの既存の約束と同じ)。初期状態は塗る前の線画そのものなので、 

1490 パレット絞り込みなどの表示フィルタは掛けない。 

1491 """ 

1492 if self.show_initial_state and layer.initial_image is not None: 

1493 return layer.initial_image 

1494 return self._display_layer_image(layer, layer_index) 

1495 

1496 def _display_layer_image(self, layer, layer_index): 

1497 """パレット/シルエット等の表示フィルタを適用したレイヤー画像を返す。 

1498 

1499 消しゴム(白)は既にレイヤーデータ上で alpha=0 のため、疑似透明の表示変換は 

1500 不要。ストローク中もアクティブレイヤーの実画素へ直接合成しているので、 

1501 ここで ``layer.image`` を返せばそのまま最新の描画結果が表示される。 

1502 """ 

1503 # 下書きレイヤーは色数削減の対象外。プレビューの色をそのまま出力する 

1504 # ため、パレット絞り込みやシルエット等の表示変換を一切適用しない。 

1505 if getattr(layer, "is_draft", False): 

1506 return layer.image 

1507 apply_palette_filter = ( 

1508 layer_index == self.active_layer_index 

1509 ) 

1510 if ( 1510 ↛ 1514line 1510 didn't jump to line 1514 because the condition on line 1510 was never true

1511 self.silhouette_non_background 

1512 and not layer.is_paper 

1513 ): 

1514 return self.silhouette_layer_image( 

1515 layer, 

1516 apply_palette_filter, 

1517 ) 

1518 

1519 return self.filtered_layer_image( 

1520 layer, 

1521 apply_palette_filter, 

1522 ) 

1523 

1524 def view_layer_image(self, layer, layer_index, image): 

1525 """``image`` に表示プレビュー(露光量・チャンネル・隣接色分け)を掛けた複製。 

1526 

1527 画面に描くときだけ使う。:meth:`composite` は書き出しやサムネイルにも 

1528 使われるので、ここは通さない。隣接色分けはアクティブレイヤーだけに掛け、 

1529 描いている最中も割り当て済みの色を維持する。 

1530 """ 

1531 preview = self.view_preview 

1532 if image is None or image.isNull() or not preview.active: 

1533 return image 

1534 if getattr(layer, "is_draft", False): 1534 ↛ 1535line 1534 didn't jump to line 1535 because the condition on line 1534 was never true

1535 return image 

1536 map_colors = ( 

1537 preview.map_colors 

1538 and layer_index == self.active_layer_index 

1539 and not layer.is_paper 

1540 ) 

1541 if not map_colors and not preview.adjusts_pixels: 1541 ↛ 1542line 1541 didn't jump to line 1542 because the condition on line 1541 was never true

1542 return image 

1543 stroke_preview = getattr(self, "_stroke_view_preview", None) 

1544 if (map_colors and stroke_preview is not None 

1545 and image is layer.image and stroke_preview[0] is layer 

1546 and stroke_preview[1] == preview.key()): 

1547 # 筆が触れた範囲だけを変換し、ストローク専用の表示画像へ戻す。 

1548 # 過去のプレビューキャッシュは共有しない。 

1549 result = stroke_preview[2] 

1550 dirty = self._stroke_view_dirty.intersected(image.rect()) 

1551 if not dirty.isEmpty(): 

1552 patch = self.view_layer_image(layer, layer_index, image.copy(dirty)) 

1553 if patch is not None: 1553 ↛ 1560line 1553 didn't jump to line 1560 because the condition on line 1553 was always true

1554 painter = QPainter(result) 

1555 painter.setCompositionMode( 

1556 QPainter.CompositionMode.CompositionMode_Source 

1557 ) 

1558 painter.drawImage(dirty.topLeft(), patch) 

1559 painter.end() 

1560 self._stroke_view_dirty = QRect() 

1561 return result 

1562 try: 

1563 image_key = int(image.cacheKey()) 

1564 except (AttributeError, RuntimeError, TypeError) as exc: 

1565 log.debug("cacheKey() unavailable, using id() fallback: %s", exc) 

1566 image_key = id(image) 

1567 key = (image_key, image.width(), image.height(), preview.key(), map_colors) 

1568 cached = self._view_preview_cache.get(key) 

1569 if cached is not None: 

1570 return cached 

1571 rgba = image.convertToFormat(QImage.Format.Format_RGBA8888) 

1572 width, height = rgba.width(), rgba.height() 

1573 rows = np.frombuffer( 

1574 imaging.qimage_buffer(rgba), dtype=np.uint8 

1575 ).reshape((height, rgba.bytesPerLine())) 

1576 pixels = rows[:, :width * 4].reshape((height, width, 4)) 

1577 if map_colors: 1577 ↛ 1580line 1577 didn't jump to line 1580 because the condition on line 1577 was always true

1578 mapping = self._view_map_color_mappings.setdefault(preview.palette, {}) 

1579 view_preview.map_color_pixels(pixels, palette=preview.palette, color_mapping=mapping) 

1580 view_preview.adjust_pixels(pixels, preview) 

1581 result = rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

1582 _bounded_cache_put(self._view_preview_cache, key, result, 32) 

1583 return result 

1584 

1585 def view_background_color(self): 

1586 """キャンバスの背景(擬似透明)の表示色に、表示プレビューを掛けた色。""" 

1587 color = QColor(self.transparent_display_color) 

1588 if not self.view_preview.adjusts_pixels: 1588 ↛ 1590line 1588 didn't jump to line 1590 because the condition on line 1588 was always true

1589 return color 

1590 red, green, blue = view_preview.background_color( 

1591 (color.red(), color.green(), color.blue()), self.view_preview 

1592 ) 

1593 return QColor(red, green, blue) 

1594 

1595 def paint_view_background(self, painter, rect): 

1596 """Draw the view-only checker when the configured background matches.""" 

1597 from PySide6.QtGui import QBrush 

1598 

1599 color = self.transparent_display_color 

1600 if (not preferences.background_checker_enabled() 

1601 or (color.red(), color.green(), color.blue()) 

1602 != preferences.background_checker_color()): 

1603 painter.fillRect(rect, self.view_background_color()) 

1604 return 

1605 tile = QPixmap(16, 16) 

1606 tile.fill(QColor(240, 240, 240)) 

1607 tile_painter = QPainter(tile) 

1608 tile_painter.fillRect(0, 0, 8, 8, QColor(192, 192, 192)) 

1609 tile_painter.fillRect(8, 8, 8, 8, QColor(192, 192, 192)) 

1610 tile_painter.end() 

1611 painter.fillRect(rect, QBrush(tile)) 

1612 

1613 def set_view_preview(self, **changes): 

1614 """表示プレビューの設定を変えて描き直す(``exposure=1.0`` など)。""" 

1615 if "exposure" in changes: 

1616 changes["exposure"] = view_preview.clamp_exposure(changes["exposure"]) 

1617 updated = dataclasses.replace(self.view_preview, **changes) 

1618 if updated == self.view_preview: 1618 ↛ 1619line 1618 didn't jump to line 1619 because the condition on line 1618 was never true

1619 return 

1620 self.view_preview = updated 

1621 self.update() 

1622 

1623 def composite(self, fi, white=True): 

1624 result = blank_image(QColor("white") if white else Qt.GlobalColor.transparent) 

1625 painter = QPainter(result) 

1626 layer_count = len(self.frames[fi].layers) 

1627 for layer_index in range(layer_count): 

1628 key_frame = self.resolve_key_frame(fi, layer_index) 

1629 if key_frame is None: 

1630 continue 

1631 layer = self.frames[key_frame].layers[layer_index] 

1632 if layer.visible: 1632 ↛ 1627line 1632 didn't jump to line 1627 because the condition on line 1632 was always true

1633 display_image = self._display_layer_image(layer, layer_index) 

1634 if display_image is not None: 1634 ↛ 1627line 1634 didn't jump to line 1627 because the condition on line 1634 was always true

1635 painter.setOpacity(layer.opacity) 

1636 painter.drawImage(0, 0, display_image) 

1637 painter.end() 

1638 return result 

1639 

1640 

1641 def _display_image_for_rect(self, image, target_rect): 

1642 """Keep bounded, device-resolution previews; source pixels stay intact.""" 

1643 if image is None or image.isNull(): 1643 ↛ 1644line 1643 didn't jump to line 1644 because the condition on line 1643 was never true

1644 return image 

1645 if self.drawing: 

1646 return image.mirrored(self.flip_horizontal, self.flip_vertical) if ( 

1647 self.flip_horizontal or self.flip_vertical 

1648 ) else image 

1649 ratio = max(1.0, float(self.devicePixelRatioF())) 

1650 width = min(image.width(), max(1, round(target_rect.width() * ratio))) 

1651 height = min(image.height(), max(1, round(target_rect.height() * ratio))) 

1652 if (width, height) == (image.width(), image.height()) and not ( 

1653 self.flip_horizontal or self.flip_vertical 

1654 ): 

1655 return image 

1656 mode = preferences.display_shrink_mode() 

1657 key = ( 

1658 int(image.cacheKey()), width, height, 

1659 self.flip_horizontal, self.flip_vertical, mode, 

1660 ) 

1661 cache = getattr(self, "_display_image_cache", None) 

1662 if cache is None: 

1663 self._display_image_cache = cache = {} 

1664 self._display_image_cache_bytes = 0 

1665 cached = cache.pop(key, None) 

1666 if cached is not None: 

1667 cache[key] = cached 

1668 return cached 

1669 if mode == display_shrink.MODE_KEEP_LINES: 1669 ↛ 1671line 1669 didn't jump to line 1671 because the condition on line 1669 was never true

1670 # 縮小だけを任せる。拡大や等倍はニアレストのままでよい。 

1671 result = display_shrink.shrink_keeping_lines(image, width, height) 

1672 else: 

1673 result = image.scaled(width, height, Qt.AspectRatioMode.IgnoreAspectRatio, 

1674 Qt.TransformationMode.FastTransformation) 

1675 if self.flip_horizontal or self.flip_vertical: 

1676 result = result.mirrored(self.flip_horizontal, self.flip_vertical) 

1677 size = result.sizeInBytes() 

1678 budget = 64 * 1024 * 1024 

1679 if size <= budget: 1679 ↛ 1685line 1679 didn't jump to line 1685 because the condition on line 1679 was always true

1680 while cache and (self._display_image_cache_bytes + size > budget or len(cache) >= 96): 

1681 oldest = cache.pop(next(iter(cache))) 

1682 self._display_image_cache_bytes -= oldest.sizeInBytes() 

1683 cache[key] = result 

1684 self._display_image_cache_bytes += size 

1685 return result 

1686 

1687 def draw_frame_direct(self, painter, target_rect, frame_index): 

1688 """Draw the current frame directly to the widget, avoiding a full-size 

1689 temporary composite on every brush mouse move.""" 

1690 # A repaint can be scheduled while the document is mid-swap (e.g. during 

1691 # new_doc/replace_doc), leaving frame_index transiently out of range. 

1692 # Skip the draw rather than crash the paintEvent. 

1693 if not (0 <= frame_index < len(self.frames)): 1693 ↛ 1694line 1693 didn't jump to line 1694 because the condition on line 1693 was never true

1694 return 

1695 layer_count = len(self.frames[frame_index].layers) 

1696 for layer_index in range(layer_count): 

1697 key_frame = self.resolve_key_frame(frame_index, layer_index) 

1698 if key_frame is None: 

1699 continue 

1700 layer = self.frames[key_frame].layers[layer_index] 

1701 if not layer.visible: 1701 ↛ 1702line 1701 didn't jump to line 1702 because the condition on line 1701 was never true

1702 continue 

1703 draw_image = self._layer_display_source(layer, layer_index) 

1704 draw_image = self.view_layer_image(layer, layer_index, draw_image) 

1705 if draw_image is None: 1705 ↛ 1706line 1705 didn't jump to line 1706 because the condition on line 1705 was never true

1706 continue 

1707 draw_image = self._display_image_for_rect(draw_image, target_rect) 

1708 painter.setOpacity(layer.opacity) 

1709 painter.drawImage(target_rect, draw_image) 

1710 painter.setOpacity(1.0) 

1711 def _active_tool_panel(self): 

1712 window: Any = self.window() 

1713 return window.tools if hasattr(window, "tools") else None 

1714 

1715 def _prepare_draw_painter(self, painter, transparent=False): 

1716 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

1717 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) 

1718 self.apply_selection_clip(painter) 

1719 painter.setCompositionMode( 

1720 QPainter.CompositionMode.CompositionMode_Source 

1721 ) 

1722 

1723 @staticmethod 

1724 def is_pseudo_transparent_color(color): 

1725 """True when ``color`` is the eraser sentinel (#FFFFFF). 

1726 

1727 White strokes erase (alpha=0) rather than painting, so callers use this 

1728 to avoid recording white as a used colour. 

1729 """ 

1730 return colors.is_pseudo_transparent_color(color) 

1731 

1732 def _current_stroke_color(self): 

1733 color = self.opaque_paint_color() 

1734 return color, self.is_pseudo_transparent_color(color) 

1735 

1736 def paintEvent(self,e): 

1737 p=QPainter(self) 

1738 p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) 

1739 p.fillRect(self.rect(),QColor(42,42,42)); wr=self.work_rect(); cr=self.canvas_rect() 

1740 p.save(); c=wr.center(); p.translate(c); p.rotate(self.rotation); p.scale(max(0.0001,float(self.onion_tu_tb_scale)),max(0.0001,float(self.onion_tu_tb_scale))); p.translate(-c) 

1741 p.fillRect(wr,QColor(72,72,72)) 

1742 self.paint_view_background(p, cr) 

1743 p.setOpacity(1) 

1744 # 再生中は表示領域サイズの合成キャッシュを使用し、 

1745 # 大画像をレイヤーごとに毎フレーム再縮小しない。 

1746 if self._playback_active: 

1747 playback_image = self.playback_frame_image( 

1748 self.current_frame, 

1749 wr, 

1750 ) 

1751 if playback_image is not None: 1751 ↛ 1757line 1751 didn't jump to line 1757 because the condition on line 1751 was always true

1752 p.drawImage(wr, playback_image) 

1753 else: 

1754 # 編集時は元解像度のレイヤーを直接描画する。 

1755 self.draw_frame_direct(p, wr, self.current_frame) 

1756 # オニオンスキンは現在コマより手前へ重ねる。 

1757 if self.onion_skin: 1757 ↛ 1758line 1757 didn't jump to line 1758 because the condition on line 1757 was never true

1758 self._draw_onion_range( 

1759 p, wr, -1, self.onion_previous_count, 

1760 self.onion_previous_opacity, self.onion_previous_color, 

1761 self.onion_previous_color_enabled, 

1762 self.onion_selected_colors_only, 

1763 ) 

1764 self._draw_onion_range( 

1765 p, wr, 1, self.onion_next_count, 

1766 self.onion_next_opacity, self.onion_next_color, 

1767 self.onion_next_color_enabled, 

1768 self.onion_selected_colors_only, 

1769 ) 

1770 p.setOpacity(1) 

1771 if self.transform_active and self.transform_points: 

1772 quality_requested = ( 

1773 bool(getattr(self, "transform_quality_active", False)) 

1774 and self.transform_drag_kind is None 

1775 and not self._transform_line_adjusting 

1776 ) 

1777 quality_ready = ( 

1778 quality_requested 

1779 and self._tp_preview_cache_image is not None 

1780 and not self._tp_preview_progress_busy 

1781 ) 

1782 if quality_requested and not quality_ready: 

1783 if not self._tp_preview_progress_busy: 1783 ↛ 1784line 1783 didn't jump to line 1784 because the condition on line 1783 was never true

1784 self.request_quality_preview_counter() 

1785 # Dragging/repainting stays responsive while the scheduled 

1786 # TP_mask job displays its counter popup. 

1787 transform_preview, _ = self.transform_preview_image( 

1788 quality=False, 

1789 preview_only=True, 

1790 ) 

1791 else: 

1792 transform_preview, _ = self.transform_preview_image( 

1793 quality=quality_ready, 

1794 preview_only=True, 

1795 ) 

1796 if transform_preview is not None: 1796 ↛ 1811line 1796 didn't jump to line 1811 because the condition on line 1796 was always true

1797 if quality_ready and self.transform_original_layer is not None: 

1798 transform_preview = self._merge_quality_transform( 

1799 self.transform_original_layer, 

1800 transform_preview, 

1801 ) 

1802 if transform_preview is None: 1802 ↛ 1803line 1802 didn't jump to line 1803 because the condition on line 1802 was never true

1803 transform_preview = QImage() 

1804 transform_preview = ( 

1805 transform_preview.mirrored(self.flip_horizontal, self.flip_vertical) 

1806 if self.flip_horizontal or self.flip_vertical else transform_preview 

1807 ) 

1808 p.setOpacity(1 if quality_ready else .85) 

1809 p.drawImage(wr, transform_preview) 

1810 p.setOpacity(1) 

1811 self.paint_skewer_guide(p, wr) 

1812 p.setPen(QPen(QColor(15,15,15),1));p.drawRect(cr);p.restore() 

1813 self.paint_skewer_banner(p) 

1814 # ライン/図形の確定前プレビュー 

1815 if self.line_start is not None and self.line_end is not None: 1815 ↛ 1816line 1815 didn't jump to line 1816 because the condition on line 1815 was never true

1816 preview_color = self.opaque_paint_color() 

1817 preview_color.setAlpha(220) 

1818 p.setPen(QPen( 

1819 preview_color, 

1820 max(0.5, float(self.pen_size) * self.zoom), 

1821 Qt.PenStyle.SolidLine, 

1822 Qt.PenCapStyle.RoundCap, 

1823 Qt.PenJoinStyle.RoundJoin, 

1824 )) 

1825 line_path = QPainterPath(self.canvas_to_widget(self.line_start)) 

1826 if self.line_curve_stage == 2 and self.line_control is not None: 

1827 line_path.quadTo( 

1828 self.canvas_to_widget(self.line_control), 

1829 self.canvas_to_widget(self.line_end), 

1830 ) 

1831 else: 

1832 line_path.lineTo(self.canvas_to_widget(self.line_end)) 

1833 p.drawPath(line_path) 

1834 

1835 if self.shape_start is not None and self.shape_end is not None: 1835 ↛ 1836line 1835 didn't jump to line 1836 because the condition on line 1835 was never true

1836 rect_preview = self._shape_rect() 

1837 panel = self._active_tool_panel() 

1838 shape_type = panel.shape_type.currentData() if panel is not None else "polygon" 

1839 preview_points = [] 

1840 if not rect_preview.isEmpty(): 

1841 center = rect_preview.center() 

1842 if shape_type == "ellipse": 

1843 point_count = 64 

1844 for index in range(point_count): 

1845 angle = -math.pi / 2.0 + 2.0 * math.pi * index / point_count 

1846 canvas_point = QPointF( 

1847 center.x() + math.cos(angle) * rect_preview.width() / 2.0, 

1848 center.y() + math.sin(angle) * rect_preview.height() / 2.0, 

1849 ) 

1850 preview_points.append(self.canvas_to_widget(canvas_point)) 

1851 else: 

1852 point_count = max( 

1853 3, 

1854 int(panel.shape_corners.value()) if panel is not None else 4, 

1855 ) 

1856 if point_count == 4: 

1857 canvas_points = [ 

1858 QPointF(rect_preview.left(), rect_preview.top()), 

1859 QPointF(rect_preview.right(), rect_preview.top()), 

1860 QPointF(rect_preview.right(), rect_preview.bottom()), 

1861 QPointF(rect_preview.left(), rect_preview.bottom()), 

1862 ] 

1863 preview_points.extend( 

1864 self.canvas_to_widget(point) for point in canvas_points 

1865 ) 

1866 else: 

1867 for index in range(point_count): 

1868 angle = -math.pi / 2.0 + 2.0 * math.pi * index / point_count 

1869 canvas_point = QPointF( 

1870 center.x() + math.cos(angle) * rect_preview.width() / 2.0, 

1871 center.y() + math.sin(angle) * rect_preview.height() / 2.0, 

1872 ) 

1873 preview_points.append(self.canvas_to_widget(canvas_point)) 

1874 if preview_points: 

1875 preview_color = self.opaque_paint_color() 

1876 preview_color.setAlpha(220) 

1877 width = ( 

1878 panel.shape_outline_width.value() / 2.0 

1879 if panel is not None else 1.0 

1880 ) 

1881 p.setPen(QPen( 

1882 preview_color, 

1883 max(0.5, float(width) * self.zoom), 

1884 Qt.PenStyle.SolidLine, 

1885 )) 

1886 p.setBrush(Qt.BrushStyle.NoBrush) 

1887 preview_path = QPainterPath(preview_points[0]) 

1888 for point in preview_points[1:]: 

1889 preview_path.lineTo(point) 

1890 preview_path.closeSubpath() 

1891 p.drawPath(preview_path) 

1892 

1893 # overlays are drawn in widget coordinates so rotation is applied only once 

1894 preview_selection = self.lasso 

1895 if preview_selection: 1895 ↛ 1896line 1895 didn't jump to line 1896 because the condition on line 1895 was never true

1896 p.setPen(QPen(QColor(255,70,70),2,Qt.PenStyle.DashLine)); 

1897 pts=[self.canvas_to_widget(q) for q in preview_selection] 

1898 for a,b in zip(pts,pts[1:]):p.drawLine(a,b) 

1899 elif self.selection_polygon: 

1900 outlines = ( 

1901 self.selection_outline_polygons 

1902 if self.selection_outline_polygons 

1903 else [self.selection_polygon] 

1904 ) 

1905 p.save() 

1906 p.setOpacity(self._selection_fade_opacity) 

1907 p.setCompositionMode( 

1908 QPainter.CompositionMode.CompositionMode_Difference 

1909 ) 

1910 p.setPen(QPen(QColor(255,255,255),2,Qt.PenStyle.DashLine)) 

1911 for outline in outlines: 

1912 display_outline = ( 

1913 self.transformed_selection_outline(outline) 

1914 if self.transform_active else outline 

1915 ) 

1916 pts = [ 

1917 self.canvas_to_widget(point) 

1918 for point in display_outline 

1919 ] 

1920 for first, second in zip(pts, pts[1:]): 

1921 p.drawLine(first, second) 

1922 if len(pts) > 2: 1922 ↛ 1911line 1922 didn't jump to line 1911 because the condition on line 1922 was always true

1923 p.drawLine(pts[-1], pts[0]) 

1924 p.restore() 

1925 if self.rect_start is not None and self.rect_end is not None: 1925 ↛ 1926line 1925 didn't jump to line 1926 because the condition on line 1925 was never true

1926 a=self.canvas_to_widget(self.rect_start); b=self.canvas_to_widget(self.rect_end) 

1927 p.setPen(QPen(QColor(255,70,70),2,Qt.PenStyle.DashLine)) 

1928 p.drawRect(QRectF(a,b).normalized()) 

1929 if self.transform_active and self.transform_points: 

1930 p.setPen(QPen(QColor(0,220,255),2)) 

1931 points=[self.canvas_to_widget(q) for q in self.transform_points] 

1932 if self.transform_mode=="mesh": 1932 ↛ 1933line 1932 didn't jump to line 1933 because the condition on line 1932 was never true

1933 cols=max(2,int(getattr(self, "transform_mesh_cols", 4))) 

1934 rows=max(2,int(getattr(self, "transform_mesh_rows", 4))) 

1935 if len(points)==cols*rows: 

1936 for gy in range(rows): 

1937 for gx in range(cols-1): 

1938 p.drawLine(points[gy*cols+gx],points[gy*cols+gx+1]) 

1939 for gx in range(cols): 

1940 for gy in range(rows-1): 

1941 p.drawLine(points[gy*cols+gx],points[(gy+1)*cols+gx]) 

1942 elif len(points)==4: 1942 ↛ 1945line 1942 didn't jump to line 1945 because the condition on line 1942 was always true

1943 for index in range(4): 

1944 p.drawLine(points[index],points[(index+1)%4]) 

1945 rotation_handle=self.transform_rotation_handle() 

1946 if rotation_handle is not None: 1946 ↛ 1962line 1946 didn't jump to line 1962 because the condition on line 1946 was always true

1947 rotation_widget=self.canvas_to_widget(rotation_handle) 

1948 if self.transform_mode == "mesh": 1948 ↛ 1949line 1948 didn't jump to line 1949 because the condition on line 1948 was never true

1949 cols=max(2,int(getattr(self, "transform_mesh_cols", 4))) 

1950 left=self.transform_points[0] 

1951 right=self.transform_points[cols-1] 

1952 else: 

1953 left=self.transform_points[0] 

1954 right=self.transform_points[1] 

1955 top_mid=self.canvas_to_widget(QPointF( 

1956 (left.x()+right.x())/2, 

1957 (left.y()+right.y())/2, 

1958 )) 

1959 p.drawLine(top_mid,rotation_widget) 

1960 p.setBrush(QColor(255,220,70)) 

1961 p.drawEllipse(rotation_widget,6,6) 

1962 p.setBrush(QColor("white")) 

1963 for point in points:p.drawEllipse(point,5,5) 

1964 if self.mesh_points: 1964 ↛ 1965line 1964 didn't jump to line 1965 because the condition on line 1964 was never true

1965 p.setPen(QPen(QColor(0,210,255),1)); g=self.mesh_grid 

1966 for gy in range(g): 

1967 for gx in range(g-1):p.drawLine(self.canvas_to_widget(self.mesh_points[gy*g+gx]),self.canvas_to_widget(self.mesh_points[gy*g+gx+1])) 

1968 for gx in range(g): 

1969 for gy in range(g-1):p.drawLine(self.canvas_to_widget(self.mesh_points[gy*g+gx]),self.canvas_to_widget(self.mesh_points[(gy+1)*g+gx])) 

1970 p.setBrush(QColor("white")); 

1971 for q in self.mesh_points: 

1972 wq=self.canvas_to_widget(q);p.drawEllipse(wq,5,5) 

1973 if ( 

1974 self.effective_tool() in ("brush", "line") 

1975 and self._brush_cursor_inside 

1976 and self.rect().contains(self._brush_cursor_widget_pos.toPoint()) 

1977 ): 

1978 center = self.widget_to_canvas(self._brush_cursor_widget_pos) 

1979 outline = stamp_outline(center, max(0.5, float(self.pen_size))) 

1980 p.save() 

1981 p.setBrush(Qt.BrushStyle.NoBrush) 

1982 p.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

1983 p.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference) 

1984 cursor_pen = QPen(QColor("white"), 1.0) 

1985 cursor_pen.setCosmetic(True) 

1986 p.setPen(cursor_pen) 

1987 for polygon in outline.toSubpathPolygons(): 

1988 p.drawPolygon(QPolygonF([self.canvas_to_widget(polygon.at(i)) for i in range(polygon.size())])) 

1989 p.restore() 

1990 

1991 # Hold-to-use canvas operations are not entries in ToolPanel.TOOLS. 

1992 hold_labels = { 

1993 "hand": tr("ハンド"), 

1994 "zoom": tr("拡大縮小"), 

1995 "rotate": tr("回転"), 

1996 "eyedropper": tr("スポイト"), 

1997 } 

1998 tool = self.effective_tool() 

1999 tool_name = hold_labels.get(tool) or tool_label(tool) 

2000 p.setPen(QColor("white")) 

2001 p.drawText( 

2002 10, self.height()-10, 

2003 f"{tool_name} | {self.zoom*100:.0f}% | Frame {self.current_frame+1}" 

2004 ) 

2005 p.end()