mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-12-26 09:28:36 -07:00
186 lines
8 KiB
Python
186 lines
8 KiB
Python
# Copyright (c) 2025 UltiMaker
|
|
# Cura is released under the terms of the LGPLv3 or higher.
|
|
|
|
import os
|
|
from PyQt6.QtCore import QRect
|
|
from typing import Optional, List, Tuple, Dict
|
|
|
|
from PyQt6.QtGui import QImage, QColor, QPainter
|
|
|
|
from cura.CuraApplication import CuraApplication
|
|
from UM.PluginRegistry import PluginRegistry
|
|
from UM.View.GL.ShaderProgram import ShaderProgram
|
|
from UM.View.GL.Texture import Texture
|
|
from UM.View.View import View
|
|
from UM.Scene.Selection import Selection
|
|
from UM.View.GL.OpenGL import OpenGL
|
|
from UM.i18n import i18nCatalog
|
|
from UM.Math.Color import Color
|
|
|
|
catalog = i18nCatalog("cura")
|
|
|
|
|
|
class PaintView(View):
|
|
"""View for model-painting."""
|
|
|
|
UNDO_STACK_SIZE = 1024
|
|
|
|
class PaintType:
|
|
def __init__(self, display_color: Color, value: int):
|
|
self.display_color: Color = display_color
|
|
self.value: int = value
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self._paint_shader: Optional[ShaderProgram] = None
|
|
self._current_paint_texture: Optional[Texture] = None
|
|
self._current_bits_ranges: tuple[int, int] = (0, 0)
|
|
self._current_paint_type = ""
|
|
self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {}
|
|
|
|
self._stroke_undo_stack: List[Tuple[QImage, int, int]] = []
|
|
self._stroke_redo_stack: List[Tuple[QImage, int, int]] = []
|
|
|
|
self._force_opaque_mask = QImage(2, 2, QImage.Format.Format_Mono)
|
|
self._force_opaque_mask.fill(1)
|
|
|
|
CuraApplication.getInstance().engineCreatedSignal.connect(self._makePaintModes)
|
|
|
|
def _makePaintModes(self):
|
|
theme = CuraApplication.getInstance().getTheme()
|
|
usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0),
|
|
"preferred": self.PaintType(Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
|
|
"avoid": self.PaintType(Color(*theme.getColor("paint_avoid_area").getRgb()), 2)}
|
|
self._paint_modes = {
|
|
"seam": usual_types,
|
|
"support": usual_types,
|
|
}
|
|
|
|
def _checkSetup(self):
|
|
if not self._paint_shader:
|
|
shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader")
|
|
self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename)
|
|
|
|
def _forceOpaqueDeepCopy(self, image: QImage):
|
|
res = QImage(image.width(), image.height(), QImage.Format.Format_RGBA8888)
|
|
res.fill(QColor(255, 255, 255, 255))
|
|
painter = QPainter(res)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
|
painter.drawImage(0, 0, image)
|
|
painter.end()
|
|
res.setAlphaChannel(self._force_opaque_mask.scaled(image.width(), image.height()))
|
|
return res
|
|
|
|
def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str) -> None:
|
|
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
|
|
return
|
|
|
|
actual_image = self._current_paint_texture.getImage()
|
|
|
|
bit_range_start, bit_range_end = self._current_bits_ranges
|
|
set_value = self._paint_modes[self._current_paint_type][brush_color].value << self._current_bits_ranges[0]
|
|
full_int32 = 0xffffffff
|
|
clear_mask = full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (32 - 1 - bit_range_end))
|
|
image_rect = QRect(0, 0, stroke_mask.width(), stroke_mask.height())
|
|
|
|
clear_bits_image = stroke_mask.copy()
|
|
clear_bits_image.invertPixels()
|
|
painter = QPainter(clear_bits_image)
|
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten)
|
|
painter.fillRect(image_rect, clear_mask)
|
|
painter.end()
|
|
|
|
set_value_image = stroke_mask.copy()
|
|
painter = QPainter(set_value_image)
|
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply)
|
|
painter.fillRect(image_rect, set_value)
|
|
painter.end()
|
|
|
|
stroked_image = actual_image.copy(start_x, start_y, stroke_mask.width(), stroke_mask.height())
|
|
painter = QPainter(stroked_image)
|
|
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination)
|
|
painter.drawImage(0, 0, clear_bits_image)
|
|
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
|
|
painter.drawImage(0, 0, set_value_image)
|
|
painter.end()
|
|
|
|
self._stroke_redo_stack.clear()
|
|
if len(self._stroke_undo_stack) >= PaintView.UNDO_STACK_SIZE:
|
|
self._stroke_undo_stack.pop(0)
|
|
undo_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(stroked_image, start_x, start_y))
|
|
if undo_image is not None:
|
|
self._stroke_undo_stack.append((undo_image, start_x, start_y))
|
|
|
|
def _applyUndoStacksAction(self, from_stack: List[Tuple[QImage, int, int]], to_stack: List[Tuple[QImage, int, int]]) -> bool:
|
|
if len(from_stack) <= 0 or self._current_paint_texture is None:
|
|
return False
|
|
from_image, x, y = from_stack.pop()
|
|
to_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(from_image, x, y))
|
|
if to_image is None:
|
|
return False
|
|
if len(to_stack) >= PaintView.UNDO_STACK_SIZE:
|
|
to_stack.pop(0)
|
|
to_stack.append((to_image, x, y))
|
|
return True
|
|
|
|
def undoStroke(self) -> bool:
|
|
return self._applyUndoStacksAction(self._stroke_undo_stack, self._stroke_redo_stack)
|
|
|
|
def redoStroke(self) -> bool:
|
|
return self._applyUndoStacksAction(self._stroke_redo_stack, self._stroke_undo_stack)
|
|
|
|
def getUvTexDimensions(self):
|
|
if self._current_paint_texture is not None:
|
|
return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight()
|
|
return 0, 0
|
|
|
|
def setPaintType(self, paint_type: str) -> None:
|
|
node = Selection.getAllSelectedObjects()[0]
|
|
if node is None:
|
|
return
|
|
|
|
paint_data_mapping = node.callDecoration("getTextureDataMapping")
|
|
|
|
if paint_type not in paint_data_mapping:
|
|
new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[paint_type]))
|
|
paint_data_mapping[paint_type] = new_mapping
|
|
node.callDecoration("setTextureDataMapping", paint_data_mapping)
|
|
|
|
self._current_paint_type = paint_type
|
|
self._current_bits_ranges = paint_data_mapping[paint_type]
|
|
|
|
@staticmethod
|
|
def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]:
|
|
start_index = 0
|
|
if actual_mapping:
|
|
start_index = max(end_index for _, end_index in actual_mapping.values()) + 1
|
|
|
|
end_index = start_index + int.bit_length(nb_storable_values - 1) - 1
|
|
|
|
return start_index, end_index
|
|
|
|
def beginRendering(self) -> None:
|
|
renderer = self.getRenderer()
|
|
self._checkSetup()
|
|
paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
|
|
renderer.addRenderBatch(paint_batch)
|
|
|
|
node = Selection.getSelectedObject(0)
|
|
if node is None:
|
|
return
|
|
|
|
if self._current_paint_type == "":
|
|
return
|
|
|
|
self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0])
|
|
self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])
|
|
|
|
colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].values()]
|
|
colors_values = [[int(color_part * 255) for color_part in [color.r, color.g, color.b]] for color in colors]
|
|
self._paint_shader.setUniformValueArray("u_renderColors", colors_values)
|
|
|
|
self._current_paint_texture = node.callDecoration("getPaintTexture")
|
|
self._paint_shader.setTexture(0, self._current_paint_texture)
|
|
|
|
paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
|