Coverage for paintmaskanimator/canvas_selection.py: 81%

525 statements  

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

1"""Selection state, transform, and clipboard for :class:`PaintCanvas`. 

2 

3Split out of ``canvas.py`` as a mixin. These methods own the selection mask, 

4its fade overlay, the begin/commit/cancel free-transform + rotate flow, and 

5copy/cut/paste of the selected region. They run against a live ``PaintCanvas`` 

6instance and reuse its frame/layer state and the TP-transform mask helpers. 

7""" 

8import math 

9import numpy as np 

10import time 

11from typing import Any 

12from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, Qt 

13from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF, QRegion 

14from PySide6.QtWidgets import QApplication 

15from .i18n import tr 

16from .time_display import frame_position 

17from .optional_deps import PILImage, PILImageFilter 

18from .constants import OUTSIDE_MARGIN 

19from ._canvas_members import CanvasMembers 

20from . import constants, imaging 

21from .progress import close_counter, create_counter, update_counter 

22from .logging_setup import get_logger 

23from .undo_entries import LayerBatchUndo, LayerUndo, TransformUndo 

24 

25log = get_logger(__name__) 

26 

27 

28class SelectionMixin(CanvasMembers): 

29 def selection_mask_bool(self, width=None, height=None, offset_x=0, offset_y=0): 

30 """Return a boolean mask for the active selection in the requested local area.""" 

31 if not self.selection_polygon: 

32 return None 

33 width = int(width if width is not None else self.active_layer.image.width()) 

34 height = int(height if height is not None else self.active_layer.image.height()) 

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

36 return np.zeros((max(0, height), max(0, width)), dtype=bool) 

37 if self.selection_mask_override is not None: 

38 source = np.asarray(self.selection_mask_override, dtype=bool) 

39 result = np.zeros((height, width), dtype=bool) 

40 source_x1 = max(0, int(offset_x)) 

41 source_y1 = max(0, int(offset_y)) 

42 source_x2 = min(source.shape[1], int(offset_x) + width) 

43 source_y2 = min(source.shape[0], int(offset_y) + height) 

44 if source_x2 > source_x1 and source_y2 > source_y1: 44 ↛ 51line 44 didn't jump to line 51 because the condition on line 44 was always true

45 target_x1 = source_x1 - int(offset_x) 

46 target_y1 = source_y1 - int(offset_y) 

47 result[ 

48 target_y1:target_y1 + source_y2 - source_y1, 

49 target_x1:target_x1 + source_x2 - source_x1, 

50 ] = source[source_y1:source_y2, source_x1:source_x2] 

51 return result 

52 mask = QImage(width, height, QImage.Format.Format_RGBA8888) 

53 mask.fill(Qt.GlobalColor.transparent) 

54 local_polygon = QPolygonF([ 

55 QPointF(point.x() - offset_x, point.y() - offset_y) 

56 for point in self.selection_polygon 

57 ]) 

58 painter = QPainter(mask) 

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

60 painter.setPen(Qt.PenStyle.NoPen) 

61 painter.setBrush(QColor(255, 255, 255, 255)) 

62 painter.drawPolygon(local_polygon) 

63 painter.end() 

64 ptr = imaging.qimage_buffer(mask) 

65 rows = np.frombuffer(ptr, dtype=np.uint8).reshape( 

66 (height, mask.bytesPerLine()) 

67 ) 

68 return rows[:, :width * 4].reshape((height, width, 4))[:, :, 3].copy() > 0 

69 

70 @staticmethod 

71 def _bool_mask_image(*args, **kwargs): 

72 return imaging.bool_mask_image(*args, **kwargs) 

73 

74 def _update_selection_fade(self): 

75 phase = ( 

76 (time.monotonic() - self._selection_fade_started) % 2.0 

77 ) / 2.0 

78 self._selection_fade_opacity = ( 

79 0.08 + 0.92 * (0.5 + 0.5 * math.cos(phase * math.tau)) 

80 ) 

81 if self.selection_polygon: 

82 if self.transform_active and not self.transform_outer_polygon().isEmpty(): 

83 selection_rect = self.transform_outer_polygon().boundingRect() 

84 else: 

85 selection_rect = ( 

86 self.selection_mask_rect 

87 if self.selection_mask_rect is not None 

88 else QRectF(self.selection_bounds()) 

89 ) 

90 corners = [ 

91 self.canvas_to_widget(selection_rect.topLeft()), 

92 self.canvas_to_widget(selection_rect.topRight()), 

93 self.canvas_to_widget(selection_rect.bottomRight()), 

94 self.canvas_to_widget(selection_rect.bottomLeft()), 

95 ] 

96 xs = [point.x() for point in corners] 

97 ys = [point.y() for point in corners] 

98 dirty = QRectF( 

99 min(xs), min(ys), 

100 max(xs) - min(xs), max(ys) - min(ys), 

101 ).adjusted(-4, -4, 4, 4).toAlignedRect() 

102 region = QRegion(dirty.intersected(self.rect())) 

103 if self._brush_cursor_inside: 

104 cursor_radius = max( 

105 10, 

106 int(math.ceil( 

107 float(self.pen_size) * max(self.zoom, 0.01) / 2.0 

108 )) + 6, 

109 ) 

110 cursor_rect = QRectF( 

111 self._brush_cursor_widget_pos, 

112 self._brush_cursor_widget_pos, 

113 ).adjusted( 

114 -cursor_radius, -cursor_radius, 

115 cursor_radius, cursor_radius, 

116 ).toAlignedRect() 

117 region -= QRegion(cursor_rect) 

118 if not region.isEmpty(): 118 ↛ exitline 118 didn't return from function '_update_selection_fade' because the condition on line 118 was always true

119 self.update(region) 

120 

121 def _clear_selection_state(self): 

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

123 self.cancel_selection_transform() 

124 self.selection_polygon = [] 

125 self.selection_mask_override = None 

126 self.selection_outline_polygons = [] 

127 self.selection_mask_rect = None 

128 self.lasso = [] 

129 self.rect_start = None 

130 self.rect_end = None 

131 self.selectionChanged.emit() 

132 self.update() 

133 

134 def clear_selection(self): 

135 self._clear_selection_state() 

136 self.selectionCleared.emit() 

137 

138 def set_selection_mask(self, mask): 

139 """真偽マスク(レイヤー全体の大きさ)をそのまま選択範囲にする。 

140 

141 空なら選択を解除する。輪郭が取れなければ何もしないで False。 

142 """ 

143 if not np.any(mask): 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 self.clear_selection() 

145 return True 

146 # 輪郭の計算は MainWindow から line_ops へ移っている。 

147 window: Any = self.window() 

148 contour_owner = window.line_ops 

149 contours = contour_owner._mask_contours(mask) 

150 contour = contour_owner._largest_contour(contours) 

151 if not contour: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 return False 

153 ys, xs = np.nonzero(mask) 

154 self.selection_polygon = contour 

155 self.selection_mask_override = mask 

156 self.selection_outline_polygons = contours 

157 self.selection_mask_rect = QRectF( 

158 int(xs.min()), int(ys.min()), 

159 int(xs.max() - xs.min() + 1), 

160 int(ys.max() - ys.min() + 1), 

161 ) 

162 self._selection_fade_started = time.monotonic() 

163 self.lasso = [] 

164 self.rect_start = None 

165 self.rect_end = None 

166 self.selectionChanged.emit() 

167 self.update() 

168 return True 

169 

170 def _canvas_area_rect(self): 

171 """レイヤー画像のうち、キャンバス(外側の余白を除く)にあたる矩形。""" 

172 return QRect( 

173 OUTSIDE_MARGIN, OUTSIDE_MARGIN, 

174 constants.CANVAS_WIDTH, constants.CANVAS_HEIGHT, 

175 ).intersected(self.active_layer.image.rect()) 

176 

177 def select_all(self): 

178 """キャンバス全体を選択する(Ctrl+A)。""" 

179 if not self.frames or not self.layers: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 return False 

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

182 return False 

183 area = self._canvas_area_rect() 

184 if area.isEmpty(): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 return False 

186 self._clear_selection_state() 

187 self.selection_polygon = [ 

188 QPointF(area.left(), area.top()), 

189 QPointF(area.left() + area.width(), area.top()), 

190 QPointF(area.left() + area.width(), area.top() + area.height()), 

191 QPointF(area.left(), area.top() + area.height()), 

192 ] 

193 self._selection_fade_started = time.monotonic() 

194 self.selectionChanged.emit() 

195 self.update() 

196 return True 

197 

198 def invert_selection(self): 

199 """選択範囲を反転する(Ctrl+Shift+I)。選択が無ければ何もしない。""" 

200 if not self.selection_polygon or self.transform_active: 

201 return False 

202 image = self.active_layer.image 

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

204 current = self.selection_mask_bool(width, height) 

205 if current is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return False 

207 # 外側の余白は選択の対象にしないので、反転もキャンバスの中だけで行う。 

208 area = self._canvas_area_rect() 

209 inverted = np.zeros((height, width), dtype=bool) 

210 y0, y1 = area.top(), area.top() + area.height() 

211 x0, x1 = area.left(), area.left() + area.width() 

212 inverted[y0:y1, x0:x1] = ~current[y0:y1, x0:x1] 

213 return self.set_selection_mask(inverted) 

214 

215 def clear_selection_preserving_used_colors(self): 

216 """内部処理用。使用色の選択を維持したまま画像選択だけ解除する。""" 

217 self._clear_selection_state() 

218 

219 def _masked_selection_source(self, rect): 

220 source = self.active_layer.image.copy(rect) 

221 if not self.selection_polygon: 

222 return source 

223 if self.selection_mask_override is not None: 

224 mask = self._bool_mask_image(self.selection_mask_bool( 

225 rect.width(), rect.height(), rect.x(), rect.y() 

226 )) 

227 painter = QPainter(source) 

228 painter.setCompositionMode( 

229 QPainter.CompositionMode.CompositionMode_DestinationIn 

230 ) 

231 painter.drawImage(0, 0, mask) 

232 painter.end() 

233 return source 

234 mask = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32_Premultiplied) 

235 mask.fill(Qt.GlobalColor.transparent) 

236 painter = QPainter(mask) 

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

238 painter.setPen(Qt.PenStyle.NoPen) 

239 painter.setBrush(QColor(255, 255, 255, 255)) 

240 painter.drawPolygon(QPolygonF([ 

241 QPointF(point.x() - rect.x(), point.y() - rect.y()) 

242 for point in self.selection_polygon 

243 ])) 

244 painter.end() 

245 painter = QPainter(source) 

246 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn) 

247 painter.drawImage(0, 0, mask) 

248 painter.end() 

249 return source 

250 

251 def auto_select_used_area(self): 

252 """Select the bounding area of every opaque pixel, including gray colors.""" 

253 if not self.frames or not self.layers: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 return False 

255 self.ensure_editable_key() 

256 image = self.active_layer.image.convertToFormat( 

257 QImage.Format.Format_RGBA8888 

258 ) 

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

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

261 return False 

262 ptr = imaging.qimage_buffer(image) 

263 rows = np.frombuffer(ptr, dtype=np.uint8).reshape( 

264 (height, image.bytesPerLine()) 

265 ) 

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

267 used = pixels[:, :, 3] > 0 

268 ys, xs = np.nonzero(used) 

269 if len(xs) == 0: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 return False 

271 left = int(xs.min()) 

272 right = int(xs.max()) + 1 

273 top = int(ys.min()) 

274 bottom = int(ys.max()) + 1 

275 self.selection_polygon = [ 

276 QPointF(left, top), 

277 QPointF(right, top), 

278 QPointF(right, bottom), 

279 QPointF(left, bottom), 

280 ] 

281 self.selectionChanged.emit() 

282 self.update() 

283 return True 

284 

285 def begin_selection_transform(self, mode): 

286 if not self.selection_polygon: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 return False 

288 selection_rect = self.selection_bounds().intersected( 

289 self.active_layer.image.rect() 

290 ) 

291 # 選択境界ぴったりで切り出すと、縮小時のサンプリングで 

292 # 外周画素が透明側へ丸められて欠ける。変形元だけに透明な 

293 # 安全余白を持たせ、選択マスク自体の形状は変更しない。 

294 rect = selection_rect.adjusted(-2, -2, 2, 2).intersected( 

295 self.active_layer.image.rect() 

296 ) 

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

298 return False 

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

300 self.cancel_selection_transform() 

301 

302 mode = mode if mode in ("scale", "free", "mesh") else "free" 

303 self.transform_active = True 

304 self.transform_mode = mode 

305 self.transform_quality_active = bool(getattr(self, "transform_quality", False)) 

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

307 self._invalidate_tp_preview_cache() 

308 self.transform_frame_index = self.current_frame 

309 self.transform_layer_index = self.active_layer_index 

310 self.transform_original_layer = self.active_layer.image.copy() 

311 self.transform_original_has_content = self.active_layer.has_content 

312 self.transform_source_rect = QRectF(rect) 

313 self.transform_source = self._masked_selection_source(rect) 

314 if self.transform_quality_active: 

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

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

317 use_proxy = self._tp_uses_proxy(target_width, target_height) 

318 if PILImage is None or PILImageFilter is None: 318 ↛ 321line 318 didn't jump to line 321 because the condition on line 318 was never true

319 # Keep the transform usable even when Pillow is unavailable, 

320 # but do not pretend that the TP_mask method is active. 

321 self.transform_quality_active = False 

322 self.status_message.emit( 

323 tr("クオリティ変形には Pillow が必要です。通常変形へ切り替えました。") 

324 ) 

325 elif use_proxy: 

326 self._clear_tp_transform_masks(clear_proxy=False) 

327 self.status_message.emit( 

328 tr("5000px以上の画像は軽量TpMaskプレビューで表示し、" 

329 "確定時に原寸で処理します。") 

330 ) 

331 elif not self._prepare_tp_transform_masks(): 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true

332 self.transform_quality_active = False 

333 self.status_message.emit( 

334 tr("TpMaskを準備できなかったため、通常変形へ切り替えました。") 

335 ) 

336 self.transform_drag_kind = None 

337 self.transform_drag_start = QPointF() 

338 self.transform_drag_points = [] 

339 

340 painter = QPainter(self.active_layer.image) 

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

342 painter.setPen(Qt.PenStyle.NoPen) 

343 painter.setBrush(QColor(0, 0, 0, 255)) 

344 if self.selection_mask_override is not None: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 painter.setCompositionMode( 

346 QPainter.CompositionMode.CompositionMode_DestinationOut 

347 ) 

348 local_mask = self.selection_mask_bool( 

349 rect.width(), rect.height(), rect.x(), rect.y() 

350 ) 

351 painter.drawImage( 

352 rect.topLeft(), self._bool_mask_image(local_mask) 

353 ) 

354 else: 

355 painter.setCompositionMode( 

356 QPainter.CompositionMode.CompositionMode_Clear 

357 ) 

358 painter.drawPolygon(QPolygonF(self.selection_polygon)) 

359 painter.end() 

360 

361 if mode == "mesh": 

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

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

364 self.transform_mesh_cols = cols 

365 self.transform_mesh_rows = rows 

366 self.transform_mesh_grid = cols 

367 self.transform_points = ( 

368 self._regular_mesh_reference_points( 

369 QRectF(rect), 

370 cols, 

371 rows, 

372 ) 

373 ) 

374 self.transform_mesh_reference_points = [ 

375 QPointF(point) for point in self.transform_points 

376 ] 

377 self._active_mesh_cols = cols 

378 self._active_mesh_rows = rows 

379 else: 

380 self.transform_mesh_reference_points = [] 

381 left, top = float(rect.left()), float(rect.top()) 

382 right, bottom = float(rect.right() + 1), float(rect.bottom() + 1) 

383 self.transform_points = [ 

384 QPointF(left, top), QPointF(right, top), 

385 QPointF(right, bottom), QPointF(left, bottom), 

386 ] 

387 self.transform_handle = -1 

388 if self.transform_quality_active: 

389 self.request_quality_preview_counter( 

390 tr("最初のクオリティプレビューを生成しています") 

391 ) 

392 self.update() 

393 return True 

394 

395 def transformed_selection_outline(self, outline): 

396 """選択輪郭を現在の変形プレビューと同じ座標へ写像する。""" 

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

398 not self.transform_active 

399 or self.transform_source_rect is None 

400 or not self.transform_points 

401 ): 

402 return [QPointF(point) for point in outline] 

403 rect = QRectF(self.transform_source_rect) 

404 width = max(1.0, float(rect.width())) 

405 height = max(1.0, float(rect.height())) 

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

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

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

409 if len(self.transform_points) != cols * rows: 

410 return [QPointF(point) for point in outline] 

411 return [ 

412 self._mesh_curve_point( 

413 self.transform_points, 

414 cols, 

415 rows, 

416 (float(point.x()) - rect.left()) / width * (cols - 1), 

417 (float(point.y()) - rect.top()) / height * (rows - 1), 

418 ) 

419 for point in outline 

420 ] 

421 if len(self.transform_points) != 4: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true

422 return [QPointF(point) for point in outline] 

423 transform = self._quad_homography( 

424 [ 

425 QPointF(0, 0), QPointF(width, 0), 

426 QPointF(width, height), QPointF(0, height), 

427 ], 

428 self.transform_points, 

429 ) 

430 return [ 

431 transform.map(QPointF( 

432 float(point.x()) - rect.left(), 

433 float(point.y()) - rect.top(), 

434 )) 

435 for point in outline 

436 ] 

437 

438 def rotate_selection_transform(self, degrees): 

439 if not self.transform_active or not self.transform_points: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true

440 return 

441 before = TransformUndo.capture(self) 

442 center = self.transform_center() 

443 angle = math.radians(float(degrees)) 

444 cs, sn = math.cos(angle), math.sin(angle) 

445 self.transform_points = [ 

446 QPointF( 

447 center.x() + (p.x() - center.x()) * cs - (p.y() - center.y()) * sn, 

448 center.y() + (p.x() - center.x()) * sn + (p.y() - center.y()) * cs, 

449 ) 

450 for p in self.transform_points 

451 ] 

452 self._record_transform_history(before) 

453 self._invalidate_tp_preview_cache() 

454 if self.transform_quality_active: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 self.request_quality_preview_counter( 

456 tr("回転後のクオリティプレビューを生成しています") 

457 ) 

458 self.update() 

459 

460 def _selection_source_from_image(self, image): 

461 rect = self.transform_source_rect.toAlignedRect().intersected(image.rect()) 

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

463 return None 

464 source = image.copy(rect) 

465 if self.selection_polygon: 465 ↛ 492line 465 didn't jump to line 492 because the condition on line 465 was always true

466 if self.selection_mask_override is not None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 mask = self._bool_mask_image(self.selection_mask_bool( 

468 rect.width(), rect.height(), rect.x(), rect.y() 

469 )) 

470 painter = QPainter(source) 

471 painter.setCompositionMode( 

472 QPainter.CompositionMode.CompositionMode_DestinationIn 

473 ) 

474 painter.drawImage(0, 0, mask) 

475 painter.end() 

476 return source 

477 mask = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32_Premultiplied) 

478 mask.fill(Qt.GlobalColor.transparent) 

479 painter = QPainter(mask) 

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

481 painter.setPen(Qt.PenStyle.NoPen) 

482 painter.setBrush(QColor(255, 255, 255, 255)) 

483 painter.drawPolygon(QPolygonF([ 

484 QPointF(point.x() - rect.x(), point.y() - rect.y()) 

485 for point in self.selection_polygon 

486 ])) 

487 painter.end() 

488 painter = QPainter(source) 

489 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn) 

490 painter.drawImage(0, 0, mask) 

491 painter.end() 

492 return source 

493 

494 def _cleared_selection_base(self, image): 

495 base = image.copy() 

496 painter = QPainter(base) 

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

498 painter.setPen(Qt.PenStyle.NoPen) 

499 painter.setBrush(QColor(0, 0, 0, 255)) 

500 if self.selection_mask_override is not None: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 painter.setCompositionMode( 

502 QPainter.CompositionMode.CompositionMode_DestinationOut 

503 ) 

504 painter.drawImage( 

505 0, 0, self._bool_mask_image(self.selection_mask_override) 

506 ) 

507 elif self.selection_polygon: 507 ↛ 512line 507 didn't jump to line 512 because the condition on line 507 was always true

508 painter.setCompositionMode( 

509 QPainter.CompositionMode.CompositionMode_Clear 

510 ) 

511 painter.drawPolygon(QPolygonF(self.selection_polygon)) 

512 elif self.transform_source_rect is not None: 

513 painter.setCompositionMode( 

514 QPainter.CompositionMode.CompositionMode_Clear 

515 ) 

516 painter.fillRect(self.transform_source_rect, QColor(0, 0, 0, 255)) 

517 painter.end() 

518 return base 

519 

520 def _merge_quality_transform(self, original, preview): 

521 """Composite transformed artwork over the remaining layer.""" 

522 result = self._cleared_selection_base(original) 

523 painter = QPainter(result) 

524 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) 

525 painter.drawImage(0, 0, preview) 

526 painter.end() 

527 return result 

528 

529 def _record_transform_history(self, before): 

530 if before != TransformUndo.capture(self): 

531 self.push_undo(before) 

532 self.selectionChanged.emit() 

533 

534 def _discard_transform_history(self): 

535 # Geometry is temporary; confirmation stores a single image edit. 

536 old_stack = list(self.undo_stack) 

537 for branch in self.history_branches: 537 ↛ 538line 537 didn't jump to line 538 because the loop on line 537 never started

538 branch.position = sum(not isinstance(e, TransformUndo) 

539 for e in old_stack[:branch.position]) 

540 branch.entries[:] = [e for e in branch.entries 

541 if not isinstance(e, TransformUndo)] 

542 self.history_branches[:] = [b for b in self.history_branches if b.entries] 

543 self.undo_stack[:] = [e for e in self.undo_stack if not isinstance(e, TransformUndo)] 

544 self.redo_stack[:] = [e for e in self.redo_stack if not isinstance(e, TransformUndo)] 

545 

546 def _reset_selection_transform(self): 

547 self._discard_transform_history() 

548 self.transform_active = False 

549 self.transform_mode = None 

550 self.transform_original_layer = None 

551 self.transform_original_has_content = False 

552 self.transform_source = None 

553 self.transform_source_rect = None 

554 self.transform_points = [] 

555 self.transform_mesh_reference_points = [] 

556 self.transform_handle = -1 

557 self.transform_drag_kind = None 

558 self.transform_drag_points = [] 

559 self.transform_quality_active = False 

560 self.transform_tp_line_colors = () 

561 self._clear_tp_transform_masks() 

562 

563 def commit_selection_transform(self, all_frames=None): 

564 if not self.transform_active: 564 ↛ 565line 564 didn't jump to line 565 because the condition on line 564 was never true

565 return 

566 window: Any = self.window() 

567 quality_active = bool(getattr(self, "transform_quality_active", False)) 

568 if all_frames is None: 

569 all_frames = bool( 

570 window.tools.selection_all_frames.isChecked() 

571 if hasattr(window, "tools") 

572 else getattr(self, "transform_apply_all_frames", False) 

573 ) 

574 if quality_active: 

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

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

577 if self._tp_uses_proxy(target_width, target_height): 

578 # 大画像の表示中キャッシュは縮小代理画像なので、確定時だけ 

579 # 原寸マスクを生成する。代理画像を確定結果へ流用しない。 

580 self._clear_tp_transform_masks(clear_proxy=False) 

581 self.refresh_quality_preview_with_counter( 

582 tr("変形確定用の原寸クオリティ画像を生成しています"), 

583 full_resolution=True, 

584 ) 

585 elif self._tp_preview_cache_image is None: 585 ↛ 589line 585 didn't jump to line 589 because the condition on line 585 was always true

586 self.refresh_quality_preview_with_counter( 

587 tr("変形確定用のクオリティ画像を生成しています") 

588 ) 

589 if self._tp_preview_cache_image is None: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 self.status_message.emit( 

591 tr("クオリティ画像を生成できなかったため、変形確定を中止しました。") 

592 ) 

593 return 

594 

595 layer_index = int(getattr(self, "transform_layer_index", self.active_layer_index)) 

596 current_index = int(getattr(self, "transform_frame_index", self.current_frame)) 

597 original_current = self.transform_original_layer 

598 original_current_has_content = getattr( 

599 self, "transform_original_has_content", self.active_layer.has_content 

600 ) 

601 frame_indices = list( 

602 range(len(self.frames)) if all_frames else [current_index] 

603 ) 

604 undo_cells = [] 

605 changed_frames = [] 

606 progress = None 

607 if all_frames and window is not None: 

608 progress = create_counter( 

609 window, 

610 tr("すべてのコマに変形"), 

611 len(frame_indices), 

612 tr("変形を準備しています"), 

613 ) 

614 

615 for progress_index, frame_index in enumerate(frame_indices, 1): 

616 if progress is not None: 

617 update_counter( 

618 progress, 

619 progress_index - 1, 

620 len(frame_indices), 

621 tr("{position}を変形しています").format( 

622 position=frame_position(frame_index)), 

623 ) 

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

625 continue 

626 frame = self.frames[frame_index] 

627 if not (0 <= layer_index < len(frame.layers)): 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 continue 

629 layer = frame.layers[layer_index] 

630 if frame_index == current_index and original_current is not None: 

631 original = original_current.copy() 

632 original_has_content = original_current_has_content 

633 else: 

634 if not layer.has_content: 634 ↛ 635line 634 didn't jump to line 635 because the condition on line 634 was never true

635 continue 

636 original = layer.image.copy() 

637 original_has_content = layer.has_content 

638 

639 source = ( 

640 self.transform_source 

641 if quality_active and frame_index == current_index 

642 and self.transform_source is not None 

643 else self._selection_source_from_image(original) 

644 ) 

645 if source is None or source.isNull(): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true

646 continue 

647 preview, _target = self.transform_preview_image( 

648 source, original.width(), original.height(), 

649 quality=quality_active, 

650 ) 

651 if preview is None: 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true

652 continue 

653 

654 if quality_active: 

655 base = self._merge_quality_transform(original, preview) 

656 else: 

657 base = self._cleared_selection_base(original) 

658 painter = QPainter(base) 

659 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) 

660 painter.drawImage(0, 0, preview) 

661 painter.end() 

662 

663 undo_cells.append((frame_index, original.copy(), original_has_content)) 

664 layer.image = base 

665 layer.has_content = True 

666 changed_frames.append(frame_index) 

667 

668 if progress is not None: 

669 close_counter(progress) 

670 

671 for frame_index in changed_frames: 

672 self.sync_numbered_image_from_cell( 

673 frame_index, 

674 layer_index, 

675 ) 

676 # 描画系ツールと同様にセル変更を通知する。これがないと 

677 # ヒストリーパネル(cellChanged/changedで更新)が確定後も 

678 # 再描画されず、変形の履歴が一覧に現れない。 

679 self.cellChanged.emit(frame_index, layer_index) 

680 

681 self._discard_transform_history() 

682 if undo_cells: 682 ↛ 692line 682 didn't jump to line 692 because the condition on line 682 was always true

683 if len(undo_cells) == 1: 

684 frame_index, image, has_content = undo_cells[0] 

685 self.push_undo( 

686 LayerUndo(frame_index, layer_index, image, has_content) 

687 ) 

688 else: 

689 self.push_undo(LayerBatchUndo(layer_index, undo_cells)) 

690 # 変形ではコマ構造と使用色の種類は変わらないため、 

691 # タイムライン全再構築・使用色全走査は行わない。 

692 elif ( 

693 original_current is not None 

694 and 0 <= current_index < len(self.frames) 

695 and 0 <= layer_index < len(self.frames[current_index].layers) 

696 ): 

697 layer = self.frames[current_index].layers[layer_index] 

698 layer.image = original_current 

699 layer.has_content = original_current_has_content 

700 

701 self._reset_selection_transform() 

702 self.transform_apply_all_frames = False 

703 # V62: a confirmed transform finishes the operation completely. 

704 self.selection_polygon = [] 

705 self.selection_mask_override = None 

706 self.selection_outline_polygons = [] 

707 self.selection_mask_rect = None 

708 self.lasso = [] 

709 self.rect_start = None 

710 self.rect_end = None 

711 self.selectionChanged.emit() 

712 self._update_selection_clear_overlay() 

713 self.update() 

714 

715 def cancel_selection_transform(self): 

716 if not self.transform_active: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true

717 return 

718 if self.transform_original_layer is not None: 718 ↛ 730line 718 didn't jump to line 730 because the condition on line 718 was always true

719 frame_index = int(getattr(self, "transform_frame_index", self.current_frame)) 

720 layer_index = int(getattr(self, "transform_layer_index", self.active_layer_index)) 

721 if ( 721 ↛ 730line 721 didn't jump to line 730 because the condition on line 721 was always true

722 0 <= frame_index < len(self.frames) 

723 and 0 <= layer_index < len(self.frames[frame_index].layers) 

724 ): 

725 layer = self.frames[frame_index].layers[layer_index] 

726 layer.image = self.transform_original_layer 

727 layer.has_content = getattr( 

728 self, "transform_original_has_content", layer.has_content 

729 ) 

730 self._reset_selection_transform() 

731 self.transform_apply_all_frames = False 

732 self.selectionChanged.emit() 

733 self.update() 

734 

735 def selection_bounds(self): 

736 if not self.selection_polygon: 

737 return QRectF(0,0,self.active_layer.image.width(),self.active_layer.image.height()).toAlignedRect() 

738 if self.selection_mask_rect is not None: 

739 return QRectF(self.selection_mask_rect).toAlignedRect() 

740 xs=[p.x() for p in self.selection_polygon]; ys=[p.y() for p in self.selection_polygon] 

741 return QRectF(min(xs),min(ys),max(xs)-min(xs),max(ys)-min(ys)).toAlignedRect() 

742 

743 def copy_selection(self): 

744 """選択範囲の形どおりに切り抜いてコピーする(外側は透明)。""" 

745 rect=self.selection_bounds().intersected(self.active_layer.image.rect()) 

746 if rect.isEmpty(): return False 746 ↛ exitline 746 didn't return from function 'copy_selection' because the return on line 746 wasn't executed

747 # 外接矩形をそのまま写すと、自動選択・反転・投げ縄の外側の画素まで入る。 

748 QApplication.clipboard().setImage(self._masked_selection_source(rect)) 

749 return True 

750 

751 def cut_selection(self): 

752 """コピーしてから、選択範囲の中だけを消す(Undo 1回で戻る)。 

753 

754 以前は消すときに多角形しか見ていなかったので、マスクで持つ選択 

755 (自動選択・反転)では消える範囲が選択の形とずれていた。 

756 """ 

757 if not self.copy_selection(): 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true

758 return False 

759 if self.selection_polygon: 

760 return self.delete_selection_contents() 

761 # 選択が無いときは、これまでどおりレイヤー全体を切り取る。 

762 rect = self.active_layer.image.rect() 

763 if not self.push_layer_region_undo(rect): 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 return False 

765 self.active_layer.image.fill(Qt.GlobalColor.transparent) 

766 self.cellChanged.emit(self.current_frame, self.active_layer_index) 

767 self.update() 

768 return True 

769 

770 def delete_selection_contents(self): 

771 """選択範囲の中を消す(透明にする)。選択が無ければ何もしない。""" 

772 if not self.selection_polygon or self.transform_active: 

773 return False 

774 self.ensure_editable_key() 

775 image = self.active_layer.image 

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

777 mask = self.selection_mask_bool(width, height) 

778 if mask is None or not mask.any(): 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true

779 return False 

780 ys, xs = np.nonzero(mask) 

781 rect = QRect( 

782 int(xs.min()), int(ys.min()), 

783 int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1), 

784 ) 

785 if not self.push_layer_region_undo(rect): 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 return False 

787 # 自動選択や反転の選択はマスクで持っているので、多角形ではなくマスクで消す。 

788 clear = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32) 

789 clear.fill(Qt.GlobalColor.transparent) 

790 keep = imaging.bool_mask_image( 

791 ~mask[rect.top():rect.bottom() + 1, rect.left():rect.right() + 1] 

792 ) 

793 painter = QPainter(clear) 

794 painter.drawImage(0, 0, image, rect.x(), rect.y(), rect.width(), rect.height()) 

795 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn) 

796 painter.drawImage(0, 0, keep) 

797 painter.end() 

798 painter = QPainter(image) 

799 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) 

800 painter.drawImage(rect.topLeft(), clear) 

801 painter.end() 

802 self.cellChanged.emit(self.current_frame, self.active_layer_index) 

803 self.update() 

804 return True 

805 

806 def paste_clipboard(self): 

807 image=QApplication.clipboard().image() 

808 if image.isNull(): return 

809 self.ensure_editable_key() 

810 rect=self.selection_bounds() 

811 pos=rect.topLeft() if self.selection_polygon else QPoint(OUTSIDE_MARGIN,OUTSIDE_MARGIN) 

812 changed_rect = QRect(pos, image.size()).intersected( 

813 self.active_layer.image.rect() 

814 ) 

815 if not self.push_layer_region_undo(changed_rect): return 

816 p=QPainter(self.active_layer.image);p.drawImage(pos,image);p.end() 

817 self.active_layer.has_content=True 

818 self.cellChanged.emit(self.current_frame,self.active_layer_index);self.update()