From f99b824e6b7e6630223e71c129afcd2b576dbee8 Mon Sep 17 00:00:00 2001 From: CoderJoe Date: Sun, 15 Mar 2026 07:56:47 -0500 Subject: [PATCH 1/2] Add initial implementation of Atlas Texture Editor with pixel editing and 3D preview features --- tools/texture-editor/editor/__init__.py | 0 tools/texture-editor/editor/color_picker.py | 119 +++++ tools/texture-editor/editor/face_panel.py | 269 ++++++++++ tools/texture-editor/editor/pixel_canvas.py | 216 ++++++++ tools/texture-editor/editor/preview_3d.py | 191 +++++++ tools/texture-editor/editor/texture_model.py | 509 +++++++++++++++++++ tools/texture-editor/main.py | 127 +++++ tools/texture-editor/requirements.txt | 5 + 8 files changed, 1436 insertions(+) create mode 100644 tools/texture-editor/editor/__init__.py create mode 100644 tools/texture-editor/editor/color_picker.py create mode 100644 tools/texture-editor/editor/face_panel.py create mode 100644 tools/texture-editor/editor/pixel_canvas.py create mode 100644 tools/texture-editor/editor/preview_3d.py create mode 100644 tools/texture-editor/editor/texture_model.py create mode 100644 tools/texture-editor/main.py create mode 100644 tools/texture-editor/requirements.txt diff --git a/tools/texture-editor/editor/__init__.py b/tools/texture-editor/editor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/texture-editor/editor/color_picker.py b/tools/texture-editor/editor/color_picker.py new file mode 100644 index 0000000..a38906c --- /dev/null +++ b/tools/texture-editor/editor/color_picker.py @@ -0,0 +1,119 @@ +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QColorDialog, + QLabel, QSpinBox, +) +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QPainter, QColor, QMouseEvent + + +class ColorSwatch(QWidget): + """Displays a color with checkerboard behind transparent colors.""" + + def __init__(self, color=(0, 0, 0, 255), parent=None): + super().__init__(parent) + self.color = color + self.setFixedSize(36, 36) + + def set_color(self, color): + self.color = color + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + # Checkerboard + cs = 9 + for row in range(4): + for col in range(4): + gray = 200 if (row + col) % 2 == 0 else 255 + painter.fillRect(col * cs, row * cs, cs, cs, QColor(gray, gray, gray)) + r, g, b, a = self.color + painter.fillRect(self.rect(), QColor(r, g, b, a)) + painter.setPen(QColor(100, 100, 100)) + painter.drawRect(0, 0, self.width() - 1, self.height() - 1) + painter.end() + + +class ColorPicker(QWidget): + """RGBA color picker with recent colors row.""" + + MAX_RECENT = 8 + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Current color swatch + pick button + brush size + top_row = QHBoxLayout() + self.swatch = ColorSwatch(model.current_color) + top_row.addWidget(self.swatch) + + pick_btn = QPushButton("Pick Color") + pick_btn.clicked.connect(self._open_dialog) + top_row.addWidget(pick_btn) + + top_row.addSpacing(12) + top_row.addWidget(QLabel("Brush:")) + self.brush_spin = QSpinBox() + self.brush_spin.setRange(1, 32) + self.brush_spin.setValue(model.brush_size) + self.brush_spin.setFixedWidth(50) + self.brush_spin.valueChanged.connect(self._on_brush_changed) + top_row.addWidget(self.brush_spin) + + top_row.addStretch() + layout.addLayout(top_row) + + # Recent colors + self._recent_colors = [] + self._recent_row = QHBoxLayout() + self._recent_row.setSpacing(2) + self._recent_swatches = [] + for _ in range(self.MAX_RECENT): + sw = ColorSwatch((200, 200, 200, 255)) + sw.setFixedSize(24, 24) + sw.setCursor(Qt.CursorShape.PointingHandCursor) + sw.mousePressEvent = lambda e, s=sw: self._pick_recent(s) + self._recent_swatches.append(sw) + self._recent_row.addWidget(sw) + self._recent_row.addStretch() + layout.addLayout(self._recent_row) + + self.model.color_changed.connect(self._sync_swatch) + + def _sync_swatch(self): + self.swatch.set_color(self.model.current_color) + + def _open_dialog(self): + r, g, b, a = self.model.current_color + initial = QColor(r, g, b, a) + color = QColorDialog.getColor( + initial, self, "Pick Color", + QColorDialog.ColorDialogOption.ShowAlphaChannel, + ) + if color.isValid(): + new_color = (color.red(), color.green(), color.blue(), color.alpha()) + self.model.current_color = new_color + self.model.color_changed.emit() + self.swatch.set_color(new_color) + self._add_recent(new_color) + + def _add_recent(self, color): + if color in self._recent_colors: + self._recent_colors.remove(color) + self._recent_colors.insert(0, color) + self._recent_colors = self._recent_colors[:self.MAX_RECENT] + for i, sw in enumerate(self._recent_swatches): + if i < len(self._recent_colors): + sw.set_color(self._recent_colors[i]) + + def _pick_recent(self, swatch): + color = swatch.color + self.model.current_color = color + self.model.color_changed.emit() + self.swatch.set_color(color) + + def _on_brush_changed(self, value): + self.model.brush_size = value diff --git a/tools/texture-editor/editor/face_panel.py b/tools/texture-editor/editor/face_panel.py new file mode 100644 index 0000000..6fea9c5 --- /dev/null +++ b/tools/texture-editor/editor/face_panel.py @@ -0,0 +1,269 @@ +import os +import yaml +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QComboBox, QFileDialog, QLabel, QMessageBox, QCompleter, +) +from PyQt6.QtCore import Qt, QSortFilterProxyModel, QStringListModel + +from editor.texture_model import FACE_NAMES + +DEFAULT_TEXTURE_DIR = os.path.normpath(os.path.join( + os.path.dirname(__file__), "..", "..", "..", + "src", "main", "resources", "atlas", "resourcepack", + "assets", "minecraft", "textures", "block", "custom", +)) + +CONFIG_DIR = os.path.normpath(os.path.join( + os.path.dirname(__file__), "..", "..", "..", + "src", "main", "resources", "atlas", "configuration", +)) + + +TEXTURE_SIZES = [16, 32, 64, 128, 256, 512, 1024] + + +class FacePanel(QWidget): + """State selector, face buttons, and file I/O controls.""" + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self._face_buttons = {} + self._texture_dir = DEFAULT_TEXTURE_DIR + self._block_ids = [] + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Texture size selector + size_row = QHBoxLayout() + size_row.addWidget(QLabel("Size:")) + self.size_combo = QComboBox() + for s in TEXTURE_SIZES: + self.size_combo.addItem(f"{s}x{s}", s) + current_idx = TEXTURE_SIZES.index(self.model.size) if self.model.size in TEXTURE_SIZES else 1 + self.size_combo.setCurrentIndex(current_idx) + self.size_combo.currentIndexChanged.connect(self._on_size_changed) + size_row.addWidget(self.size_combo, stretch=1) + layout.addLayout(size_row) + + # Block loader with searchable dropdown + layout.addSpacing(4) + layout.addWidget(QLabel("Block:")) + self.block_combo = QComboBox() + self.block_combo.setEditable(True) + self.block_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.block_combo.setPlaceholderText("Search blocks...") + self.block_combo.setCurrentIndex(-1) + self._populate_block_list() + completer = self.block_combo.completer() + if completer: + completer.setFilterMode(Qt.MatchFlag.MatchContains) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + load_block_btn = QPushButton("Load Block") + load_block_btn.clicked.connect(self._load_block) + block_row = QHBoxLayout() + block_row.addWidget(self.block_combo, stretch=1) + block_row.addWidget(load_block_btn) + layout.addLayout(block_row) + + # State selector + layout.addSpacing(8) + state_row = QHBoxLayout() + state_row.addWidget(QLabel("State:")) + self.state_combo = QComboBox() + self.state_combo.addItem(self.model.states[0].name) + self.state_combo.currentIndexChanged.connect(self._on_state_selected) + state_row.addWidget(self.state_combo, stretch=1) + layout.addLayout(state_row) + + # Face buttons + layout.addWidget(QLabel("Face:")) + face_grid = QVBoxLayout() + row1 = QHBoxLayout() + row2 = QHBoxLayout() + for i, face in enumerate(FACE_NAMES): + btn = QPushButton(face.capitalize()) + btn.setCheckable(True) + btn.setFixedWidth(70) + btn.clicked.connect(lambda checked, f=face: self._on_face_clicked(f)) + self._face_buttons[face] = btn + if i < 3: + row1.addWidget(btn) + else: + row2.addWidget(btn) + face_grid.addLayout(row1) + face_grid.addLayout(row2) + layout.addLayout(face_grid) + self._face_buttons[self.model.active_face].setChecked(True) + + # File I/O + layout.addSpacing(8) + + open_btn = QPushButton("Open Face...") + open_btn.clicked.connect(self._open_face) + layout.addWidget(open_btn) + + save_btn = QPushButton("Save Face...") + save_btn.clicked.connect(self._save_face) + layout.addWidget(save_btn) + + save_all_btn = QPushButton("Save All...") + save_all_btn.clicked.connect(self._save_all) + layout.addWidget(save_all_btn) + + layout.addStretch() + + self.model.state_changed.connect(self._sync_state_combo) + + def _on_size_changed(self, index): + new_size = self.size_combo.currentData() + if new_size and new_size != self.model.size: + self.model.resize_all(new_size) + + def _populate_block_list(self): + """Discover available blocks from YAML configs and texture filenames.""" + block_ids = set() + + # From YAML config files (primary source of truth) + if os.path.isdir(CONFIG_DIR): + for fname in os.listdir(CONFIG_DIR): + if fname.endswith(".yml"): + block_ids.add(fname[:-4]) + + # From texture filenames — only add blocks with a clear multi-face layout + if os.path.isdir(self._texture_dir): + texture_names = set() + for fname in os.listdir(self._texture_dir): + if fname.endswith(".png"): + texture_names.add(fname[:-4]) + + candidates = set() + for name in texture_names: + for suffix in ("_top", "_bottom", "_side", + "_north", "_south", "_east", "_west"): + if name.endswith(suffix): + candidates.add(name[:-len(suffix)]) + + for candidate in candidates: + if candidate in block_ids: + continue + # Skip if this candidate is a sub-texture of an already-known block + # e.g. "conveyor_belt_top" is a face of "conveyor_belt" + is_sub_texture = any( + candidate.startswith(bid + "_") for bid in block_ids + ) + if is_sub_texture: + continue + # cube_bottom_top: needs all three of _top, _bottom, _side + has_cbt = (f"{candidate}_top" in texture_names + and f"{candidate}_bottom" in texture_names + and f"{candidate}_side" in texture_names) + # cube: needs 3+ directional faces + dir_count = sum( + 1 for f in ("north", "south", "east", "west") + if f"{candidate}_{f}" in texture_names + ) + if has_cbt or dir_count >= 3: + block_ids.add(candidate) + + self._block_ids = sorted(block_ids) + self.block_combo.clear() + for bid in self._block_ids: + self.block_combo.addItem(bid) + + def _on_face_clicked(self, face): + for f, btn in self._face_buttons.items(): + btn.setChecked(f == face) + self.model.set_active_face(face) + + def _on_state_selected(self, index): + if 0 <= index < len(self.model.states): + self.model.set_active_state(index) + + def _sync_state_combo(self, index): + self.state_combo.blockSignals(True) + self.state_combo.setCurrentIndex(index) + self.state_combo.blockSignals(False) + + def _refresh_state_combo(self): + self.state_combo.blockSignals(True) + self.state_combo.clear() + for state in self.model.states: + self.state_combo.addItem(state.name) + self.state_combo.setCurrentIndex(self.model.active_state_index) + self.state_combo.blockSignals(False) + + def _load_block(self): + block_id = self.block_combo.currentText().strip() + if not block_id: + return + + # Try YAML config first + yaml_path = os.path.join(CONFIG_DIR, f"{block_id}.yml") + if os.path.exists(yaml_path): + with open(yaml_path, "r") as f: + data = yaml.safe_load(f) + if data: + self.model.load_block_from_yaml(data, self._texture_dir) + self._refresh_state_combo() + self._sync_size_combo() + return + + # Fallback to filename scanning + loaded = self.model.load_block_by_filename(block_id, self._texture_dir) + self._refresh_state_combo() + self._sync_size_combo() + if not loaded: + QMessageBox.warning( + self, "Not Found", + f"No textures found for block '{block_id}'.", + ) + + def _sync_size_combo(self): + """Update the size dropdown to reflect the model's current texture size.""" + self.size_combo.blockSignals(True) + if self.model.size in TEXTURE_SIZES: + self.size_combo.setCurrentIndex(TEXTURE_SIZES.index(self.model.size)) + self.size_combo.blockSignals(False) + + def _open_face(self): + filepath, _ = QFileDialog.getOpenFileName( + self, "Open Texture", + self._texture_dir, + "PNG Images (*.png)", + ) + if filepath: + self.model.load_face(self.model.active_face, filepath) + + def _save_face(self): + filepath, _ = QFileDialog.getSaveFileName( + self, "Save Texture", + self._texture_dir, + "PNG Images (*.png)", + ) + if filepath: + if not filepath.endswith(".png"): + filepath += ".png" + self.model.save_face(self.model.active_face, filepath) + + def _save_all(self): + directory = QFileDialog.getExistingDirectory( + self, "Save All Textures To", + self._texture_dir, + ) + if not directory: + return + for state in self.model.states: + for face in FACE_NAMES: + filename = f"{state.name}_{face}.png" + filepath = os.path.join(directory, filename) + state.faces[face].save(filepath, "PNG") + + def select_face_by_number(self, num): + """Select face by number 1-6.""" + if 1 <= num <= 6: + face = FACE_NAMES[num - 1] + self._on_face_clicked(face) diff --git a/tools/texture-editor/editor/pixel_canvas.py b/tools/texture-editor/editor/pixel_canvas.py new file mode 100644 index 0000000..b38e503 --- /dev/null +++ b/tools/texture-editor/editor/pixel_canvas.py @@ -0,0 +1,216 @@ +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter, QColor, QImage, QMouseEvent, QWheelEvent, QPen + + +class PixelCanvas(QWidget): + """Grid-based pixel editor with zoom, pan, and brush size support.""" + + MIN_ZOOM = 1.0 + MAX_ZOOM = 64.0 + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self._cached_image = None + self._painting = False + self._panning = False + self._last_pan_pos = None + self._zoom = 0.0 # 0 = fit-to-widget, >0 = manual zoom level (pixels per cell) + self._pan_x = 0.0 # pan offset in widget pixels + self._pan_y = 0.0 + self.setMinimumSize(256, 256) + self.setMouseTracking(True) + self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) + self.model.face_updated.connect(self._on_face_updated) + self._rebuild_cache() + + def _on_face_updated(self, face): + self._rebuild_cache() + self.update() + + def _cell_size(self): + """Return the current cell size in widget pixels.""" + img = self.model.get_image() + if self._zoom > 0: + return self._zoom + # Fit to widget + fit = min(self.width(), self.height()) / max(img.width, img.height) + return max(1.0, fit) + + def _rebuild_cache(self): + img = self.model.get_image() + w, h = img.width, img.height + cell = self._cell_size() + cell_int = max(1, int(cell)) + canvas_w = cell_int * w + canvas_h = cell_int * h + + qimg = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32) + painter = QPainter(qimg) + + # Draw checkerboard as two solid passes for speed + painter.fillRect(0, 0, canvas_w, canvas_h, QColor(255, 255, 255)) + check_color = QColor(200, 200, 200) + if cell_int >= 4: + check_size = max(1, cell_int // 2) + for cy in range(h): + for cx in range(w): + rx = cx * cell_int + ry = cy * cell_int + painter.fillRect(rx, ry, check_size, check_size, check_color) + painter.fillRect( + rx + check_size, ry + check_size, + check_size, check_size, check_color, + ) + + # Scale up the texture image with nearest-neighbor and draw it on top + scaled = img.resize( + (canvas_w, canvas_h), resample=0, # 0 = NEAREST + ) + raw = scaled.tobytes("raw", "BGRA") + tex_qimg = QImage(raw, canvas_w, canvas_h, QImage.Format.Format_ARGB32) + # QImage doesn't own the buffer, so we must keep a reference + self._scaled_bytes = raw + painter.drawImage(0, 0, tex_qimg) + + # Grid lines + if cell_int >= 4: + pen = QPen(QColor(180, 180, 180, 80)) + painter.setPen(pen) + for cx in range(w + 1): + painter.drawLine(cx * cell_int, 0, cx * cell_int, canvas_h) + for cy in range(h + 1): + painter.drawLine(0, cy * cell_int, canvas_w, cy * cell_int) + + painter.end() + self._cached_image = qimg + + def _canvas_origin(self): + """Top-left corner of the canvas in widget coordinates.""" + img = self.model.get_image() + cell = max(1, int(self._cell_size())) + canvas_w = cell * img.width + canvas_h = cell * img.height + ox = (self.width() - canvas_w) / 2.0 + self._pan_x + oy = (self.height() - canvas_h) / 2.0 + self._pan_y + return ox, oy + + def paintEvent(self, event): + if self._cached_image is None: + self._rebuild_cache() + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(50, 50, 50)) + ox, oy = self._canvas_origin() + painter.drawImage(int(ox), int(oy), self._cached_image) + + # Draw brush cursor + if self.underMouse() and not self._panning: + pos = self.mapFromGlobal(self.cursor().pos()) + px, py = self._pixel_at(pos) + brush = self.model.brush_size + cell = max(1, int(self._cell_size())) + cx = int(ox + (px - brush // 2) * cell) + cy = int(oy + (py - brush // 2) * cell) + painter.setPen(QColor(255, 255, 255, 160)) + painter.drawRect(cx, cy, brush * cell, brush * cell) + + painter.end() + + def resizeEvent(self, event): + self._rebuild_cache() + super().resizeEvent(event) + + def _pixel_at(self, pos): + ox, oy = self._canvas_origin() + cell = self._cell_size() + x = int((pos.x() - ox) / cell) + y = int((pos.y() - oy) / cell) + return x, y + + def _paint_at(self, pos): + """Paint a brush_size square centered on the pixel under pos.""" + cx, cy = self._pixel_at(pos) + self.model.paint_brush(cx, cy, self.model.brush_size) + + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.MiddleButton: + self._panning = True + self._last_pan_pos = event.position() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + return + + pos = event.position().toPoint() + if event.button() == Qt.MouseButton.LeftButton: + self.model.begin_stroke() + self._paint_at(pos) + self._painting = True + elif event.button() == Qt.MouseButton.RightButton: + x, y = self._pixel_at(pos) + color = self.model.get_pixel(x, y) + if color: + self.model.current_color = tuple(color) + self.model.color_changed.emit() + + def mouseMoveEvent(self, event: QMouseEvent): + if self._panning and self._last_pan_pos is not None: + dx = event.position().x() - self._last_pan_pos.x() + dy = event.position().y() - self._last_pan_pos.y() + self._pan_x += dx + self._pan_y += dy + self._last_pan_pos = event.position() + self.update() + return + + if self._painting: + self._paint_at(event.position().toPoint()) + + # Update brush cursor position + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.MiddleButton: + self._panning = False + self._last_pan_pos = None + self.setCursor(Qt.CursorShape.ArrowCursor) + return + + if event.button() == Qt.MouseButton.LeftButton and self._painting: + self._painting = False + self.model.end_stroke() + + def wheelEvent(self, event: QWheelEvent): + delta = event.angleDelta().y() + if delta == 0: + return + + old_cell = self._cell_size() + + # Zoom toward/away from cursor position + mouse_pos = event.position() + ox, oy = self._canvas_origin() + # Pixel coordinate under the mouse before zoom + px_before = (mouse_pos.x() - ox) / old_cell + py_before = (mouse_pos.y() - oy) / old_cell + + # Adjust zoom level + factor = 1.25 if delta > 0 else 1.0 / 1.25 + if self._zoom <= 0: + self._zoom = old_cell + self._zoom = max(self.MIN_ZOOM, min(self.MAX_ZOOM, self._zoom * factor)) + + new_cell = self._cell_size() + + # Adjust pan so the pixel under the cursor stays in place + img = self.model.get_image() + new_canvas_w = int(new_cell) * img.width + new_canvas_h = int(new_cell) * img.height + new_center_ox = (self.width() - new_canvas_w) / 2.0 + new_center_oy = (self.height() - new_canvas_h) / 2.0 + target_ox = mouse_pos.x() - px_before * new_cell + target_oy = mouse_pos.y() - py_before * new_cell + self._pan_x = target_ox - new_center_ox + self._pan_y = target_oy - new_center_oy + + self._rebuild_cache() + self.update() diff --git a/tools/texture-editor/editor/preview_3d.py b/tools/texture-editor/editor/preview_3d.py new file mode 100644 index 0000000..84ad920 --- /dev/null +++ b/tools/texture-editor/editor/preview_3d.py @@ -0,0 +1,191 @@ +from PIL import Image +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QMouseEvent, QWheelEvent + +from OpenGL.GL import ( + glClearColor, glClear, glEnable, + GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT, GL_DEPTH_TEST, + GL_TEXTURE_2D, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, + glBlendFunc, glDepthMask, GL_FALSE, GL_TRUE, + glGenTextures, glBindTexture, glTexImage2D, glTexSubImage2D, + glTexParameteri, + GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_NEAREST, + GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE, + GL_RGBA, GL_UNSIGNED_BYTE, + glBegin, glEnd, glVertex3f, glTexCoord2f, glNormal3f, + GL_QUADS, + glMatrixMode, glLoadIdentity, glTranslatef, glRotatef, + GL_PROJECTION, GL_MODELVIEW, + glViewport, +) +from OpenGL.GLU import gluPerspective + +from editor.texture_model import FACE_NAMES + + +class Preview3D(QOpenGLWidget): + """OpenGL 3D block preview with per-face textures and model geometry.""" + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self.setMinimumSize(256, 256) + + self._azimuth = 30.0 + self._elevation = 25.0 + self._zoom = -3.0 + self._last_mouse_pos = None + self._textures = {} + + self.model.face_updated.connect(self._on_face_updated) + self.model.state_changed.connect(self._on_state_changed) + + def initializeGL(self): + glClearColor(0.15, 0.15, 0.15, 1.0) + glEnable(GL_DEPTH_TEST) + glEnable(GL_TEXTURE_2D) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + self._upload_all_textures() + + def _pil_to_gl_bytes(self, img): + return img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).tobytes() + + def _upload_all_textures(self): + for face in FACE_NAMES: + img = self.model.get_image(face) + tex_id = glGenTextures(1) + glBindTexture(GL_TEXTURE_2D, tex_id) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + data = self._pil_to_gl_bytes(img) + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, + img.width, img.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, data, + ) + self._textures[face] = tex_id + + def _on_face_updated(self, face): + self.makeCurrent() + img = self.model.get_image(face) + tex_id = self._textures.get(face) + if tex_id is not None: + glBindTexture(GL_TEXTURE_2D, tex_id) + data = self._pil_to_gl_bytes(img) + glTexSubImage2D( + GL_TEXTURE_2D, 0, 0, 0, + img.width, img.height, + GL_RGBA, GL_UNSIGNED_BYTE, data, + ) + self.doneCurrent() + self.update() + + def _on_state_changed(self, index): + self.makeCurrent() + for face in FACE_NAMES: + img = self.model.get_image(face) + tex_id = self._textures.get(face) + if tex_id is not None: + glBindTexture(GL_TEXTURE_2D, tex_id) + data = self._pil_to_gl_bytes(img) + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, + img.width, img.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, data, + ) + self.doneCurrent() + self.update() + + def resizeGL(self, w, h): + glViewport(0, 0, w, h) + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + aspect = w / max(h, 1) + gluPerspective(45.0, aspect, 0.1, 100.0) + glMatrixMode(GL_MODELVIEW) + + def _face_has_transparency(self, tex_key): + """Check if a face texture contains any transparent pixels.""" + img = self.model.get_image(tex_key) + if img.mode != "RGBA": + return False + extrema = img.getextrema() + # extrema[3] is (min_alpha, max_alpha) + return extrema[3][0] < 255 + + def _draw_face(self, face_data): + tex_key = face_data["texture_key"] + tex_id = self._textures.get(tex_key) + if tex_id is None: + return + glBindTexture(GL_TEXTURE_2D, tex_id) + nx, ny, nz = face_data["normal"] + glBegin(GL_QUADS) + glNormal3f(nx, ny, nz) + for i, (vx, vy, vz) in enumerate(face_data["vertices"]): + tx, ty = face_data["tex_coords"][i] + glTexCoord2f(tx, ty) + glVertex3f(vx, vy, vz) + glEnd() + + def paintGL(self): + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glMatrixMode(GL_MODELVIEW) + glLoadIdentity() + glTranslatef(0, 0, self._zoom) + glRotatef(self._elevation, 1, 0, 0) + glRotatef(self._azimuth, 0, 1, 0) + + glEnable(GL_TEXTURE_2D) + + # Collect all faces, split into opaque and transparent + opaque = [] + transparent = [] + geometry = self.model.get_geometry() + for element in geometry: + for face_data in element["faces"].values(): + tex_key = face_data["texture_key"] + if self._textures.get(tex_key) is None: + continue + if self._face_has_transparency(tex_key): + transparent.append(face_data) + else: + opaque.append(face_data) + + # Pass 1: opaque faces with depth write on + glDepthMask(GL_TRUE) + for face_data in opaque: + self._draw_face(face_data) + + # Pass 2: transparent faces with depth write off + if transparent: + glDepthMask(GL_FALSE) + for face_data in transparent: + self._draw_face(face_data) + glDepthMask(GL_TRUE) + + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.LeftButton: + self._last_mouse_pos = event.position() + + def mouseMoveEvent(self, event: QMouseEvent): + if self._last_mouse_pos is not None: + dx = event.position().x() - self._last_mouse_pos.x() + dy = event.position().y() - self._last_mouse_pos.y() + self._azimuth += dx * 0.5 + self._elevation = max(-89, min(89, self._elevation + dy * 0.5)) + self._last_mouse_pos = event.position() + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.LeftButton: + self._last_mouse_pos = None + + def wheelEvent(self, event: QWheelEvent): + delta = event.angleDelta().y() / 120.0 + self._zoom = max(-10, min(-1.5, self._zoom + delta * 0.3)) + self.update() diff --git a/tools/texture-editor/editor/texture_model.py b/tools/texture-editor/editor/texture_model.py new file mode 100644 index 0000000..d67d185 --- /dev/null +++ b/tools/texture-editor/editor/texture_model.py @@ -0,0 +1,509 @@ +import json +import os +from PIL import Image +from PyQt6.QtCore import QObject, pyqtSignal + +FACE_NAMES = ["north", "south", "east", "west", "up", "down"] + +# Maps CraftEngine parent model texture keys to face names +PARENT_MODEL_MAPPINGS = { + "cube_bottom_top": { + "top": "up", + "bottom": "down", + "side": ["north", "south", "east", "west"], + }, + "cube": { + "north": "north", + "south": "south", + "east": "east", + "west": "west", + "up": "up", + "down": "down", + }, + "cube_all": { + "all": FACE_NAMES, + }, +} + +MODELS_DIR = os.path.normpath(os.path.join( + os.path.dirname(__file__), "..", "..", "..", + "src", "main", "resources", "atlas", "resourcepack", + "assets", "minecraft", "models", +)) + +# Default cube geometry: one element, full 16x16x16 cube +DEFAULT_GEOMETRY = [{ + "faces": { + "north": { + "texture_key": "north", + "vertices": [ + (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), + (0.5, 0.5, -0.5), (-0.5, 0.5, -0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (0, 0, -1), + }, + "south": { + "texture_key": "south", + "vertices": [ + (0.5, -0.5, 0.5), (-0.5, -0.5, 0.5), + (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (0, 0, 1), + }, + "east": { + "texture_key": "east", + "vertices": [ + (0.5, -0.5, -0.5), (0.5, -0.5, 0.5), + (0.5, 0.5, 0.5), (0.5, 0.5, -0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (1, 0, 0), + }, + "west": { + "texture_key": "west", + "vertices": [ + (-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), + (-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (-1, 0, 0), + }, + "up": { + "texture_key": "up", + "vertices": [ + (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), + (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (0, 1, 0), + }, + "down": { + "texture_key": "down", + "vertices": [ + (-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), + (0.5, -0.5, -0.5), (-0.5, -0.5, -0.5), + ], + "tex_coords": [(0, 0), (1, 0), (1, 1), (0, 1)], + "normal": (0, -1, 0), + }, + }, +}] + + +def parse_model_json(model_ref): + """Load and parse a Minecraft block model JSON from a resource reference. + + model_ref: e.g. 'minecraft:block/custom/conveyor_belt_base' + Returns parsed geometry list, or None if not found. + """ + if ":" in model_ref: + _, path = model_ref.split(":", 1) + else: + path = model_ref + json_path = os.path.join(MODELS_DIR, path + ".json") + if not os.path.exists(json_path): + return None + + with open(json_path, "r") as f: + model_data = json.load(f) + + elements = model_data.get("elements", []) + if not elements: + return None + + geometry = [] + for element in elements: + from_pos = element["from"] + to_pos = element["to"] + + # Normalize from 0-16 Minecraft coords to -0.5..0.5 GL coords + x1 = from_pos[0] / 16.0 - 0.5 + y1 = from_pos[1] / 16.0 - 0.5 + z1 = from_pos[2] / 16.0 - 0.5 + x2 = to_pos[0] / 16.0 - 0.5 + y2 = to_pos[1] / 16.0 - 0.5 + z2 = to_pos[2] / 16.0 - 0.5 + + faces = {} + for face_dir, face_data in element.get("faces", {}).items(): + # Resolve texture variable name → texture key + tex_ref = face_data.get("texture", "") + tex_key = tex_ref.lstrip("#") if tex_ref.startswith("#") else face_dir + + # UV: explicit or default to full texture + if "uv" in face_data: + mu1, mv1, mu2, mv2 = face_data["uv"] + else: + mu1, mv1, mu2, mv2 = 0, 0, 16, 16 + + # Normalize UV to 0..1, flip v (Minecraft is top-down, GL is bottom-up) + nu1 = mu1 / 16.0 + nu2 = mu2 / 16.0 + gl_v_bottom = 1.0 - mv2 / 16.0 + gl_v_top = 1.0 - mv1 / 16.0 + tc = [ + (nu1, gl_v_bottom), (nu2, gl_v_bottom), + (nu2, gl_v_top), (nu1, gl_v_top), + ] + + # Vertices: 4 corners of the quad (BL, BR, TR, TL from outside) + if face_dir == "north": + verts = [ + (x1, y1, z1), (x2, y1, z1), (x2, y2, z1), (x1, y2, z1), + ] + normal = (0, 0, -1) + elif face_dir == "south": + verts = [ + (x2, y1, z2), (x1, y1, z2), (x1, y2, z2), (x2, y2, z2), + ] + normal = (0, 0, 1) + elif face_dir == "east": + verts = [ + (x2, y1, z1), (x2, y1, z2), (x2, y2, z2), (x2, y2, z1), + ] + normal = (1, 0, 0) + elif face_dir == "west": + verts = [ + (x1, y1, z2), (x1, y1, z1), (x1, y2, z1), (x1, y2, z2), + ] + normal = (-1, 0, 0) + elif face_dir == "up": + verts = [ + (x1, y2, z1), (x2, y2, z1), (x2, y2, z2), (x1, y2, z2), + ] + normal = (0, 1, 0) + elif face_dir == "down": + verts = [ + (x1, y1, z2), (x2, y1, z2), (x2, y1, z1), (x1, y1, z1), + ] + normal = (0, -1, 0) + else: + continue + + faces[face_dir] = { + "texture_key": tex_key, + "vertices": verts, + "tex_coords": tc, + "normal": normal, + } + + geometry.append({"faces": faces}) + + return geometry + + +class BlockState: + """A single block state containing 6 face textures and optional geometry.""" + + def __init__(self, name, size=32): + self.name = name + self.geometry = None # None = default cube, otherwise parsed model + self.faces = {} + for face in FACE_NAMES: + self.faces[face] = Image.new("RGBA", (size, size), (200, 200, 200, 255)) + + +class TextureModel(QObject): + """Shared data model holding multi-state block textures.""" + + face_updated = pyqtSignal(str) + state_changed = pyqtSignal(int) + color_changed = pyqtSignal() + + def __init__(self, size=32): + super().__init__() + self.size = size + self.states = [BlockState("default", size)] + self.active_state_index = 0 + self.active_face = "north" + self.current_color = (0, 0, 0, 255) + self.brush_size = 1 + self._undo_stack = [] + self._redo_stack = [] + self._stroke_snapshot = None + + @property + def active_state(self): + return self.states[self.active_state_index] + + def get_geometry(self): + """Return the active state's geometry, or the default cube.""" + geo = self.active_state.geometry + return geo if geo is not None else DEFAULT_GEOMETRY + + def get_image(self, face=None, state_index=None): + if state_index is None: + state_index = self.active_state_index + if face is None: + face = self.active_face + return self.states[state_index].faces[face] + + def set_active_face(self, face): + if face in FACE_NAMES: + self.active_face = face + self.face_updated.emit(face) + + def set_active_state(self, index): + if 0 <= index < len(self.states): + self.active_state_index = index + self.state_changed.emit(index) + self.face_updated.emit(self.active_face) + + def add_state(self, name): + self.states.append(BlockState(name, self.size)) + + def resize_all(self, new_size): + """Resize all face textures across all states to a new size.""" + self.size = new_size + for state in self.states: + for face in FACE_NAMES: + old_img = state.faces[face] + if old_img.width == new_size and old_img.height == new_size: + continue + state.faces[face] = old_img.resize( + (new_size, new_size), Image.Resampling.NEAREST, + ) + self._undo_stack.clear() + self._redo_stack.clear() + self.state_changed.emit(self.active_state_index) + self.face_updated.emit(self.active_face) + + def begin_stroke(self): + """Save a snapshot before a paint stroke begins.""" + self._stroke_snapshot = self.get_image().copy() + + def end_stroke(self): + """Finalize a paint stroke, pushing the snapshot to undo stack.""" + if self._stroke_snapshot is not None: + self._undo_stack.append(( + self.active_state_index, + self.active_face, + self._stroke_snapshot, + )) + if len(self._undo_stack) > 20: + self._undo_stack.pop(0) + self._redo_stack.clear() + self._stroke_snapshot = None + + def undo(self): + if not self._undo_stack: + return + state_idx, face, snapshot = self._undo_stack.pop() + current = self.states[state_idx].faces[face].copy() + self._redo_stack.append((state_idx, face, current)) + self.states[state_idx].faces[face] = snapshot + if state_idx == self.active_state_index: + self.face_updated.emit(face) + + def redo(self): + if not self._redo_stack: + return + state_idx, face, snapshot = self._redo_stack.pop() + current = self.states[state_idx].faces[face].copy() + self._undo_stack.append((state_idx, face, current)) + self.states[state_idx].faces[face] = snapshot + if state_idx == self.active_state_index: + self.face_updated.emit(face) + + def set_pixel(self, x, y, emit=True): + img = self.get_image() + if 0 <= x < img.width and 0 <= y < img.height: + img.putpixel((x, y), self.current_color) + if emit: + self.face_updated.emit(self.active_face) + + def paint_brush(self, cx, cy, brush_size): + """Paint a brush_size square centered on (cx, cy). Emits once.""" + img = self.get_image() + offset = brush_size // 2 + changed = False + for dy in range(brush_size): + for dx in range(brush_size): + px = cx - offset + dx + py = cy - offset + dy + if 0 <= px < img.width and 0 <= py < img.height: + img.putpixel((px, py), self.current_color) + changed = True + if changed: + self.face_updated.emit(self.active_face) + + def get_pixel(self, x, y): + img = self.get_image() + if 0 <= x < img.width and 0 <= y < img.height: + return img.getpixel((x, y)) + return None + + def load_face(self, face, filepath): + img = Image.open(filepath).convert("RGBA") + self.active_state.faces[face] = img + if self.size != img.width: + self.size = img.width + self.face_updated.emit(face) + + def save_face(self, face, filepath): + self.active_state.faces[face].save(filepath, "PNG") + + def _load_state_from_generation(self, state_name, generation, texture_dir): + """Build a BlockState from a CraftEngine generation block.""" + parent = generation.get("parent", "") + textures = generation.get("textures", {}) + + parent_type = None + for ptype in PARENT_MODEL_MAPPINGS: + if ptype in parent: + parent_type = ptype + break + + # If no known parent matched but textures use standard face keys, + # treat it as a cube-style mapping (e.g. custom parent models) + if not parent_type and textures: + cube_keys = set(PARENT_MODEL_MAPPINGS["cube"].keys()) + if any(k in cube_keys for k in textures): + parent_type = "cube" + + if not parent_type or not textures: + return None + + block_state = BlockState(state_name, self.size) + mapping = PARENT_MODEL_MAPPINGS[parent_type] + + for tex_key, tex_path in textures.items(): + if tex_key not in mapping: + continue + filename = tex_path.split("/")[-1] + ".png" + filepath = os.path.join(texture_dir, filename) + if os.path.exists(filepath): + img = Image.open(filepath).convert("RGBA") + if self.size != img.width: + self.size = img.width + target_faces = mapping[tex_key] + if isinstance(target_faces, str): + target_faces = [target_faces] + for face in target_faces: + block_state.faces[face] = img.copy() + + # Try parsing the parent as a custom model JSON for geometry + if parent_type == "cube" and parent: + parsed = parse_model_json(parent) + if parsed: + block_state.geometry = parsed + + return block_state + + def load_block_from_yaml(self, yaml_data, texture_dir): + """Load all states from parsed CraftEngine YAML config.""" + self.states.clear() + self.active_state_index = 0 + + sections = [] + for key in yaml_data: + if key.startswith("items"): + sections.append((key, yaml_data[key])) + + # Sort so 'items' comes first, then 'items#1', 'items#2', etc. + sections.sort(key=lambda x: (x[0] != "items", x[0])) + + for section_key, section_data in sections: + for item_id, item_config in section_data.items(): + state_name = item_id.split(":")[-1] if ":" in item_id else item_id + behavior = item_config.get("behavior", {}) + block = behavior.get("block", {}) + + # Format 1: simple single-state — state.model.generation + state_section = block.get("state", {}) + model = state_section.get("model", {}) + generation = model.get("generation", {}) + if generation: + bs = self._load_state_from_generation( + state_name, generation, texture_dir, + ) + if bs: + self.states.append(bs) + continue + + # Format 2: directional / multi-appearance — states.appearances + states_section = block.get("states", {}) + appearances = states_section.get("appearances", {}) + if appearances: + for app_name, app_data in appearances.items(): + app_model = app_data.get("model", {}) + app_gen = app_model.get("generation", {}) + if not app_gen: + continue + display_name = f"{state_name} ({app_name})" + bs = self._load_state_from_generation( + display_name, app_gen, texture_dir, + ) + if bs: + self.states.append(bs) + + if not self.states: + self.states.append(BlockState("default", self.size)) + + self.active_state_index = 0 + self.active_face = "north" + self.state_changed.emit(0) + self.face_updated.emit(self.active_face) + + def load_block_by_filename(self, block_id, texture_dir): + """Fallback: discover textures by filename pattern.""" + self.states.clear() + self.active_state_index = 0 + + block_state = BlockState(block_id, self.size) + loaded_any = False + + # Try cube_bottom_top layout: {id}_top, {id}_bottom, {id}_side + top_path = os.path.join(texture_dir, f"{block_id}_top.png") + bottom_path = os.path.join(texture_dir, f"{block_id}_bottom.png") + side_path = os.path.join(texture_dir, f"{block_id}_side.png") + + if (os.path.exists(top_path) and os.path.exists(bottom_path) + and os.path.exists(side_path)): + top_img = Image.open(top_path).convert("RGBA") + bottom_img = Image.open(bottom_path).convert("RGBA") + side_img = Image.open(side_path).convert("RGBA") + self.size = top_img.width + block_state = BlockState(block_id, self.size) + block_state.faces["up"] = top_img + block_state.faces["down"] = bottom_img + for face in ["north", "south", "east", "west"]: + block_state.faces[face] = side_img.copy() + loaded_any = True + else: + # Try cube layout: {id}_north, {id}_south, etc. + face_map = {} + for face in FACE_NAMES: + path = os.path.join(texture_dir, f"{block_id}_{face}.png") + if os.path.exists(path): + face_map[face] = path + + if face_map: + first_img = Image.open(list(face_map.values())[0]).convert("RGBA") + self.size = first_img.width + block_state = BlockState(block_id, self.size) + for face, path in face_map.items(): + block_state.faces[face] = Image.open(path).convert("RGBA") + loaded_any = True + else: + # Try cube_all layout: just {id}.png + all_path = os.path.join(texture_dir, f"{block_id}.png") + if os.path.exists(all_path): + img = Image.open(all_path).convert("RGBA") + self.size = img.width + block_state = BlockState(block_id, self.size) + for face in FACE_NAMES: + block_state.faces[face] = img.copy() + loaded_any = True + + self.states.append(block_state) + if not loaded_any: + self.states[0] = BlockState("default", self.size) + + self.active_state_index = 0 + self.active_face = "north" + self.state_changed.emit(0) + self.face_updated.emit(self.active_face) + return loaded_any diff --git a/tools/texture-editor/main.py b/tools/texture-editor/main.py new file mode 100644 index 0000000..5108248 --- /dev/null +++ b/tools/texture-editor/main.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Atlas Texture Editor — edit block textures and preview them on a 3D cube.""" + +import sys +import os +import argparse + +from PyQt6.QtWidgets import QApplication, QMainWindow, QSplitter, QVBoxLayout, QWidget +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QKeySequence, QShortcut + +from editor.texture_model import TextureModel +from editor.pixel_canvas import PixelCanvas +from editor.color_picker import ColorPicker +from editor.preview_3d import Preview3D +from editor.face_panel import FacePanel + + +class TextureEditor(QMainWindow): + def __init__(self, size=32, block_id=None): + super().__init__() + self.setWindowTitle("Atlas Texture Editor") + self.resize(1100, 650) + + self.model = TextureModel(size) + + # Central splitter: left (canvas + color picker) | right (3D preview) + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left side: canvas + color picker + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(4, 4, 4, 4) + + self.canvas = PixelCanvas(self.model) + left_layout.addWidget(self.canvas, stretch=1) + + self.color_picker = ColorPicker(self.model) + left_layout.addWidget(self.color_picker) + + splitter.addWidget(left) + + # Middle: 3D preview + self.preview = Preview3D(self.model) + splitter.addWidget(self.preview) + + # Right side: face panel + self.face_panel = FacePanel(self.model) + self.face_panel.setFixedWidth(280) + splitter.addWidget(self.face_panel) + + splitter.setSizes([380, 380, 280]) + self.setCentralWidget(splitter) + + self._setup_shortcuts() + + # Load block if specified via CLI + if block_id: + self._load_initial_block(block_id) + + def _load_initial_block(self, block_id): + """Load a block by ID at startup.""" + import yaml + from editor.face_panel import CONFIG_DIR, DEFAULT_TEXTURE_DIR + yaml_path = os.path.join(CONFIG_DIR, f"{block_id}.yml") + if os.path.exists(yaml_path): + with open(yaml_path, "r") as f: + data = yaml.safe_load(f) + if data: + self.model.load_block_from_yaml(data, DEFAULT_TEXTURE_DIR) + self.face_panel._refresh_state_combo() + return + self.model.load_block_by_filename(block_id, DEFAULT_TEXTURE_DIR) + self.face_panel._refresh_state_combo() + + def _setup_shortcuts(self): + # Ctrl+S: save current face + save_sc = QShortcut(QKeySequence("Ctrl+S"), self) + save_sc.activated.connect(self.face_panel._save_face) + + # Ctrl+Z: undo + undo_sc = QShortcut(QKeySequence("Ctrl+Z"), self) + undo_sc.activated.connect(self.model.undo) + + # Ctrl+Shift+Z: redo + redo_sc = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) + redo_sc.activated.connect(self.model.redo) + + # 1-6: face selection + for i in range(1, 7): + sc = QShortcut(QKeySequence(str(i)), self) + sc.activated.connect(lambda n=i: self.face_panel.select_face_by_number(n)) + + # [ / ]: decrease / increase brush size + dec_brush = QShortcut(QKeySequence("["), self) + dec_brush.activated.connect(self._decrease_brush) + inc_brush = QShortcut(QKeySequence("]"), self) + inc_brush.activated.connect(self._increase_brush) + + def _decrease_brush(self): + new_val = max(1, self.model.brush_size - 1) + self.model.brush_size = new_val + self.color_picker.brush_spin.setValue(new_val) + + def _increase_brush(self): + new_val = min(32, self.model.brush_size + 1) + self.model.brush_size = new_val + self.color_picker.brush_spin.setValue(new_val) + + +def main(): + parser = argparse.ArgumentParser(description="Atlas Texture Editor") + parser.add_argument( + "--size", type=int, default=32, choices=[16, 32, 64, 128, 256, 512, 1024], + help="Texture size in pixels (default: 32)", + ) + parser.add_argument("--block", type=str, default=None, help="Block ID to load (e.g. fluid_pump)") + args = parser.parse_args() + + app = QApplication(sys.argv) + window = TextureEditor(size=args.size, block_id=args.block) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/tools/texture-editor/requirements.txt b/tools/texture-editor/requirements.txt new file mode 100644 index 0000000..b21e31f --- /dev/null +++ b/tools/texture-editor/requirements.txt @@ -0,0 +1,5 @@ +PyQt6>=6.6.0 +PyOpenGL>=3.1.7 +Pillow>=10.0.0 +PyYAML>=6.0 +numpy>=1.21.0 From 68e96a4b4616e6c8b6d7cfe967d821703bfa7b57 Mon Sep 17 00:00:00 2001 From: CoderJoe Date: Sun, 15 Mar 2026 18:14:38 -0500 Subject: [PATCH 2/2] Add layers, delta undo, shape symmetry, gradient selection, and reference overlay to texture editor - Layer system: each face supports multiple layers with opacity and visibility controls, add/delete/merge-down/reorder via new layer panel - Delta-based undo: tracks dirty regions during painting and stores only changed pixel crops instead of full image snapshots, raising the undo limit from 20 to 100 - Shape tool symmetry: line, rectangle, and ellipse tools now respect horizontal, vertical, and quad mirror modes - Gradient selection constraint: gradient tool fills only within the active selection rectangle when one exists - Reference image overlay: load any image as a semi-transparent tracing guide on the canvas with adjustable opacity (controls hidden until a reference is loaded) --- tools/texture-editor/editor/color_picker.py | 105 ++- tools/texture-editor/editor/face_panel.py | 141 +++- tools/texture-editor/editor/layer_panel.py | 146 ++++ tools/texture-editor/editor/pixel_canvas.py | 120 ++-- tools/texture-editor/editor/preview_3d.py | 15 +- tools/texture-editor/editor/texture_model.py | 514 ++++++++++++-- tools/texture-editor/editor/tile_preview.py | 57 ++ tools/texture-editor/editor/tools.py | 705 +++++++++++++++++++ tools/texture-editor/main.py | 98 ++- 9 files changed, 1743 insertions(+), 158 deletions(-) create mode 100644 tools/texture-editor/editor/layer_panel.py create mode 100644 tools/texture-editor/editor/tile_preview.py create mode 100644 tools/texture-editor/editor/tools.py diff --git a/tools/texture-editor/editor/color_picker.py b/tools/texture-editor/editor/color_picker.py index a38906c..7c1ef54 100644 --- a/tools/texture-editor/editor/color_picker.py +++ b/tools/texture-editor/editor/color_picker.py @@ -1,10 +1,13 @@ from PyQt6.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QColorDialog, - QLabel, QSpinBox, + QLabel, QSpinBox, QComboBox, QCheckBox, ) -from PyQt6.QtCore import Qt, QSize +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QPainter, QColor, QMouseEvent +from editor.texture_model import SYMMETRY_MODES +from editor.tools import ALL_TOOLS + class ColorSwatch(QWidget): """Displays a color with checkerboard behind transparent colors.""" @@ -20,12 +23,13 @@ def set_color(self, color): def paintEvent(self, event): painter = QPainter(self) - # Checkerboard cs = 9 for row in range(4): for col in range(4): gray = 200 if (row + col) % 2 == 0 else 255 - painter.fillRect(col * cs, row * cs, cs, cs, QColor(gray, gray, gray)) + painter.fillRect( + col * cs, row * cs, cs, cs, QColor(gray, gray, gray), + ) r, g, b, a = self.color painter.fillRect(self.rect(), QColor(r, g, b, a)) painter.setPen(QColor(100, 100, 100)) @@ -34,10 +38,16 @@ def paintEvent(self, event): class ColorPicker(QWidget): - """RGBA color picker with recent colors row.""" + """RGBA color picker with brush size, symmetry, and tool controls.""" MAX_RECENT = 8 + tool_changed = pyqtSignal(str) + filled_changed = pyqtSignal(bool) + end_color_changed = pyqtSignal(tuple) + eyedropper_clicked = pyqtSignal() + fill_clicked = pyqtSignal() + def __init__(self, model, parent=None): super().__init__(parent) self.model = model @@ -45,7 +55,7 @@ def __init__(self, model, parent=None): layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) - # Current color swatch + pick button + brush size + # Row 1: color swatch + pick button + brush size + symmetry top_row = QHBoxLayout() self.swatch = ColorSwatch(model.current_color) top_row.addWidget(self.swatch) @@ -54,6 +64,16 @@ def __init__(self, model, parent=None): pick_btn.clicked.connect(self._open_dialog) top_row.addWidget(pick_btn) + eyedrop_btn = QPushButton("Eyedropper (I)") + eyedrop_btn.setToolTip("Sample a color from the canvas") + eyedrop_btn.clicked.connect(self.eyedropper_clicked.emit) + top_row.addWidget(eyedrop_btn) + + fill_btn = QPushButton("Fill (F)") + fill_btn.setToolTip("Flood fill a region on the canvas") + fill_btn.clicked.connect(self.fill_clicked.emit) + top_row.addWidget(fill_btn) + top_row.addSpacing(12) top_row.addWidget(QLabel("Brush:")) self.brush_spin = QSpinBox() @@ -63,10 +83,54 @@ def __init__(self, model, parent=None): self.brush_spin.valueChanged.connect(self._on_brush_changed) top_row.addWidget(self.brush_spin) + top_row.addSpacing(12) + top_row.addWidget(QLabel("Mirror:")) + self.symmetry_combo = QComboBox() + for mode in SYMMETRY_MODES: + self.symmetry_combo.addItem(mode.capitalize(), mode) + self.symmetry_combo.setCurrentIndex(0) + self.symmetry_combo.currentIndexChanged.connect(self._on_symmetry_changed) + top_row.addWidget(self.symmetry_combo) + top_row.addStretch() layout.addLayout(top_row) - # Recent colors + # Row 2: tool selector + filled checkbox + tool_row = QHBoxLayout() + tool_row.addWidget(QLabel("Tool:")) + self.tool_combo = QComboBox() + for tool_cls in ALL_TOOLS: + self.tool_combo.addItem(tool_cls.name) + self.tool_combo.setCurrentIndex(0) + self.tool_combo.currentTextChanged.connect(self._on_tool_changed) + tool_row.addWidget(self.tool_combo) + + self.filled_check = QCheckBox("Filled") + self.filled_check.setVisible(False) + self.filled_check.toggled.connect(self.filled_changed.emit) + tool_row.addWidget(self.filled_check) + + tool_row.addSpacing(12) + + # Gradient end-color controls (hidden by default) + self._end_color_label = QLabel("End Color:") + self._end_color_label.setVisible(False) + tool_row.addWidget(self._end_color_label) + + self._end_swatch = ColorSwatch((255, 255, 255, 255)) + self._end_swatch.setFixedSize(24, 24) + self._end_swatch.setVisible(False) + tool_row.addWidget(self._end_swatch) + + self._end_color_btn = QPushButton("Pick") + self._end_color_btn.setVisible(False) + self._end_color_btn.clicked.connect(self._open_end_color_dialog) + tool_row.addWidget(self._end_color_btn) + + tool_row.addStretch() + layout.addLayout(tool_row) + + # Row 3: recent colors self._recent_colors = [] self._recent_row = QHBoxLayout() self._recent_row.setSpacing(2) @@ -82,6 +146,18 @@ def __init__(self, model, parent=None): layout.addLayout(self._recent_row) self.model.color_changed.connect(self._sync_swatch) + self._end_color = (255, 255, 255, 255) + + def set_filled_visible(self, visible): + self.filled_check.setVisible(visible) + + def set_end_color_visible(self, visible): + self._end_color_label.setVisible(visible) + self._end_swatch.setVisible(visible) + self._end_color_btn.setVisible(visible) + + def _on_tool_changed(self, text): + self.tool_changed.emit(text) def _sync_swatch(self): self.swatch.set_color(self.model.current_color) @@ -100,6 +176,18 @@ def _open_dialog(self): self.swatch.set_color(new_color) self._add_recent(new_color) + def _open_end_color_dialog(self): + r, g, b, a = self._end_color + initial = QColor(r, g, b, a) + color = QColorDialog.getColor( + initial, self, "Pick End Color", + QColorDialog.ColorDialogOption.ShowAlphaChannel, + ) + if color.isValid(): + self._end_color = (color.red(), color.green(), color.blue(), color.alpha()) + self._end_swatch.set_color(self._end_color) + self.end_color_changed.emit(self._end_color) + def _add_recent(self, color): if color in self._recent_colors: self._recent_colors.remove(color) @@ -117,3 +205,6 @@ def _pick_recent(self, swatch): def _on_brush_changed(self, value): self.model.brush_size = value + + def _on_symmetry_changed(self, index): + self.model.symmetry = self.symmetry_combo.currentData() diff --git a/tools/texture-editor/editor/face_panel.py b/tools/texture-editor/editor/face_panel.py index 6fea9c5..1f0c230 100644 --- a/tools/texture-editor/editor/face_panel.py +++ b/tools/texture-editor/editor/face_panel.py @@ -1,10 +1,11 @@ import os import yaml +from PIL import Image from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QComboBox, QFileDialog, QLabel, QMessageBox, QCompleter, + QComboBox, QFileDialog, QLabel, QMessageBox, QCompleter, QSlider, ) -from PyQt6.QtCore import Qt, QSortFilterProxyModel, QStringListModel +from PyQt6.QtCore import Qt from editor.texture_model import FACE_NAMES @@ -42,7 +43,10 @@ def __init__(self, model, parent=None): self.size_combo = QComboBox() for s in TEXTURE_SIZES: self.size_combo.addItem(f"{s}x{s}", s) - current_idx = TEXTURE_SIZES.index(self.model.size) if self.model.size in TEXTURE_SIZES else 1 + current_idx = ( + TEXTURE_SIZES.index(self.model.size) + if self.model.size in TEXTURE_SIZES else 1 + ) self.size_combo.setCurrentIndex(current_idx) self.size_combo.currentIndexChanged.connect(self._on_size_changed) size_row.addWidget(self.size_combo, stretch=1) @@ -88,7 +92,9 @@ def __init__(self, model, parent=None): btn = QPushButton(face.capitalize()) btn.setCheckable(True) btn.setFixedWidth(70) - btn.clicked.connect(lambda checked, f=face: self._on_face_clicked(f)) + btn.clicked.connect( + lambda checked, f=face: self._on_face_clicked(f), + ) self._face_buttons[face] = btn if i < 3: row1.addWidget(btn) @@ -99,6 +105,20 @@ def __init__(self, model, parent=None): layout.addLayout(face_grid) self._face_buttons[self.model.active_face].setChecked(True) + # Copy face + layout.addSpacing(4) + copy_row = QHBoxLayout() + copy_row.addWidget(QLabel("Copy to:")) + self.copy_target_combo = QComboBox() + for face in FACE_NAMES: + self.copy_target_combo.addItem(face.capitalize(), face) + copy_row.addWidget(self.copy_target_combo, stretch=1) + copy_btn = QPushButton("Copy") + copy_btn.setFixedWidth(50) + copy_btn.clicked.connect(self._copy_face) + copy_row.addWidget(copy_btn) + layout.addLayout(copy_row) + # File I/O layout.addSpacing(8) @@ -110,10 +130,47 @@ def __init__(self, model, parent=None): save_btn.clicked.connect(self._save_face) layout.addWidget(save_btn) - save_all_btn = QPushButton("Save All...") + self.save_block_btn = QPushButton("Save Block") + self.save_block_btn.setToolTip("Save all faces to their original file paths") + self.save_block_btn.clicked.connect(self._save_block) + self.save_block_btn.setEnabled(False) + layout.addWidget(self.save_block_btn) + + save_all_btn = QPushButton("Save All As...") save_all_btn.clicked.connect(self._save_all) layout.addWidget(save_all_btn) + # Reference image + layout.addSpacing(8) + self._ref_label = QLabel("Reference:") + self._ref_label.setVisible(False) + layout.addWidget(self._ref_label) + ref_row = QHBoxLayout() + ref_load_btn = QPushButton("Load Reference...") + ref_load_btn.setToolTip("Load a reference image to overlay on the canvas") + ref_load_btn.clicked.connect(self._load_reference) + ref_row.addWidget(ref_load_btn) + self._ref_clear_btn = QPushButton("Clear") + self._ref_clear_btn.setVisible(False) + self._ref_clear_btn.clicked.connect(self._clear_reference) + ref_row.addWidget(self._ref_clear_btn) + layout.addLayout(ref_row) + + self._ref_opacity_row = QWidget() + ref_opacity_layout = QHBoxLayout(self._ref_opacity_row) + ref_opacity_layout.setContentsMargins(0, 0, 0, 0) + ref_opacity_layout.addWidget(QLabel("Ref opacity:")) + self.ref_opacity_slider = QSlider(Qt.Orientation.Horizontal) + self.ref_opacity_slider.setRange(0, 100) + self.ref_opacity_slider.setValue(30) + self.ref_opacity_slider.valueChanged.connect(self._on_ref_opacity_changed) + ref_opacity_layout.addWidget(self.ref_opacity_slider) + self.ref_opacity_label = QLabel("30%") + self.ref_opacity_label.setFixedWidth(40) + ref_opacity_layout.addWidget(self.ref_opacity_label) + self._ref_opacity_row.setVisible(False) + layout.addWidget(self._ref_opacity_row) + layout.addStretch() self.model.state_changed.connect(self._sync_state_combo) @@ -127,13 +184,11 @@ def _populate_block_list(self): """Discover available blocks from YAML configs and texture filenames.""" block_ids = set() - # From YAML config files (primary source of truth) if os.path.isdir(CONFIG_DIR): for fname in os.listdir(CONFIG_DIR): if fname.endswith(".yml"): block_ids.add(fname[:-4]) - # From texture filenames — only add blocks with a clear multi-face layout if os.path.isdir(self._texture_dir): texture_names = set() for fname in os.listdir(self._texture_dir): @@ -150,18 +205,16 @@ def _populate_block_list(self): for candidate in candidates: if candidate in block_ids: continue - # Skip if this candidate is a sub-texture of an already-known block - # e.g. "conveyor_belt_top" is a face of "conveyor_belt" is_sub_texture = any( candidate.startswith(bid + "_") for bid in block_ids ) if is_sub_texture: continue - # cube_bottom_top: needs all three of _top, _bottom, _side - has_cbt = (f"{candidate}_top" in texture_names - and f"{candidate}_bottom" in texture_names - and f"{candidate}_side" in texture_names) - # cube: needs 3+ directional faces + has_cbt = ( + f"{candidate}_top" in texture_names + and f"{candidate}_bottom" in texture_names + and f"{candidate}_side" in texture_names + ) dir_count = sum( 1 for f in ("north", "south", "east", "west") if f"{candidate}_{f}" in texture_names @@ -201,7 +254,6 @@ def _load_block(self): if not block_id: return - # Try YAML config first yaml_path = os.path.join(CONFIG_DIR, f"{block_id}.yml") if os.path.exists(yaml_path): with open(yaml_path, "r") as f: @@ -210,25 +262,39 @@ def _load_block(self): self.model.load_block_from_yaml(data, self._texture_dir) self._refresh_state_combo() self._sync_size_combo() + self._update_save_block_btn() return - # Fallback to filename scanning - loaded = self.model.load_block_by_filename(block_id, self._texture_dir) + loaded = self.model.load_block_by_filename( + block_id, self._texture_dir, + ) self._refresh_state_combo() self._sync_size_combo() + self._update_save_block_btn() if not loaded: QMessageBox.warning( self, "Not Found", f"No textures found for block '{block_id}'.", ) + def _update_save_block_btn(self): + self.save_block_btn.setEnabled(self.model.has_source_paths()) + def _sync_size_combo(self): - """Update the size dropdown to reflect the model's current texture size.""" self.size_combo.blockSignals(True) if self.model.size in TEXTURE_SIZES: - self.size_combo.setCurrentIndex(TEXTURE_SIZES.index(self.model.size)) + self.size_combo.setCurrentIndex( + TEXTURE_SIZES.index(self.model.size), + ) self.size_combo.blockSignals(False) + def _copy_face(self): + dst = self.copy_target_combo.currentData() + src = self.model.active_face + if src == dst: + return + self.model.copy_face(src, dst) + def _open_face(self): filepath, _ = QFileDialog.getOpenFileName( self, "Open Texture", @@ -249,6 +315,16 @@ def _save_face(self): filepath += ".png" self.model.save_face(self.model.active_face, filepath) + def _save_block(self): + """Save all faces back to their original file paths.""" + saved, skipped = self.model.save_to_source_paths() + if skipped: + QMessageBox.information( + self, "Save Block", + f"Saved {saved} textures.\n" + f"Skipped {len(skipped)} faces with no source path.", + ) + def _save_all(self): directory = QFileDialog.getExistingDirectory( self, "Save All Textures To", @@ -256,11 +332,34 @@ def _save_all(self): ) if not directory: return - for state in self.model.states: + for si, state in enumerate(self.model.states): for face in FACE_NAMES: filename = f"{state.name}_{face}.png" filepath = os.path.join(directory, filename) - state.faces[face].save(filepath, "PNG") + self.model.get_composite(face, si).save(filepath, "PNG") + + def _load_reference(self): + filepath, _ = QFileDialog.getOpenFileName( + self, "Load Reference Image", + self._texture_dir, + "Images (*.png *.jpg *.jpeg *.bmp)", + ) + if filepath: + img = Image.open(filepath).convert("RGBA") + self.model.set_reference(img) + self._ref_label.setVisible(True) + self._ref_clear_btn.setVisible(True) + self._ref_opacity_row.setVisible(True) + + def _clear_reference(self): + self.model.set_reference(None) + self._ref_label.setVisible(False) + self._ref_clear_btn.setVisible(False) + self._ref_opacity_row.setVisible(False) + + def _on_ref_opacity_changed(self, value): + self.model.set_reference_opacity(value / 100.0) + self.ref_opacity_label.setText(f"{value}%") def select_face_by_number(self, num): """Select face by number 1-6.""" diff --git a/tools/texture-editor/editor/layer_panel.py b/tools/texture-editor/editor/layer_panel.py new file mode 100644 index 0000000..95d9204 --- /dev/null +++ b/tools/texture-editor/editor/layer_panel.py @@ -0,0 +1,146 @@ +"""Layer management panel — add, delete, reorder, and adjust layer properties.""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QListWidget, QListWidgetItem, QSlider, +) +from PyQt6.QtCore import Qt + + +class LayerPanel(QWidget): + """Displays and manages layers for the active face.""" + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + layout.addWidget(QLabel("Layers")) + + self.layer_list = QListWidget() + self.layer_list.currentRowChanged.connect(self._on_layer_selected) + self.layer_list.itemChanged.connect(self._on_item_changed) + layout.addWidget(self.layer_list) + + # Buttons + btn_row = QHBoxLayout() + + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.setToolTip("Add layer") + add_btn.clicked.connect(self._add_layer) + btn_row.addWidget(add_btn) + + del_btn = QPushButton("-") + del_btn.setFixedWidth(30) + del_btn.setToolTip("Delete layer") + del_btn.clicked.connect(self._delete_layer) + btn_row.addWidget(del_btn) + + merge_btn = QPushButton("Merge") + merge_btn.setToolTip("Merge active layer down") + merge_btn.clicked.connect(self._merge_down) + btn_row.addWidget(merge_btn) + + up_btn = QPushButton("Up") + up_btn.setToolTip("Move layer up") + up_btn.clicked.connect(self._move_up) + btn_row.addWidget(up_btn) + + down_btn = QPushButton("Down") + down_btn.setToolTip("Move layer down") + down_btn.clicked.connect(self._move_down) + btn_row.addWidget(down_btn) + + layout.addLayout(btn_row) + + # Opacity + opacity_row = QHBoxLayout() + opacity_row.addWidget(QLabel("Opacity:")) + self.opacity_slider = QSlider(Qt.Orientation.Horizontal) + self.opacity_slider.setRange(0, 100) + self.opacity_slider.setValue(100) + self.opacity_slider.valueChanged.connect(self._on_opacity_changed) + opacity_row.addWidget(self.opacity_slider) + self.opacity_label = QLabel("100%") + self.opacity_label.setFixedWidth(40) + opacity_row.addWidget(self.opacity_label) + layout.addLayout(opacity_row) + + self.model.face_updated.connect(lambda _: self._refresh()) + self.model.state_changed.connect(lambda _: self._refresh()) + self.model.layers_changed.connect(self._refresh) + + self._refresh() + + def _refresh(self): + state = self.model.active_state + face = self.model.active_face + layers = state.layers[face] + active_idx = state.active_layer[face] + + self.layer_list.blockSignals(True) + self.layer_list.clear() + + # Show layers top-to-bottom (highest index first) + for i in range(len(layers) - 1, -1, -1): + layer = layers[i] + item = QListWidgetItem(layer.name) + item.setData(Qt.ItemDataRole.UserRole, i) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + Qt.CheckState.Checked if layer.visible else Qt.CheckState.Unchecked + ) + self.layer_list.addItem(item) + + # Select the active layer + for row in range(self.layer_list.count()): + item = self.layer_list.item(row) + if item.data(Qt.ItemDataRole.UserRole) == active_idx: + self.layer_list.setCurrentRow(row) + break + + self.layer_list.blockSignals(False) + + # Update opacity slider + if 0 <= active_idx < len(layers): + pct = int(layers[active_idx].opacity * 100) + self.opacity_slider.blockSignals(True) + self.opacity_slider.setValue(pct) + self.opacity_slider.blockSignals(False) + self.opacity_label.setText(f"{pct}%") + + def _on_layer_selected(self, row): + if row < 0: + return + item = self.layer_list.item(row) + if item is None: + return + layer_idx = item.data(Qt.ItemDataRole.UserRole) + self.model.set_active_layer(layer_idx) + + def _on_item_changed(self, item): + layer_idx = item.data(Qt.ItemDataRole.UserRole) + checked = item.checkState() == Qt.CheckState.Checked + self.model.set_layer_visible(layer_idx, checked) + + def _add_layer(self): + self.model.add_layer() + + def _delete_layer(self): + self.model.delete_layer() + + def _merge_down(self): + self.model.merge_layer_down() + + def _move_up(self): + self.model.move_layer(1) + + def _move_down(self): + self.model.move_layer(-1) + + def _on_opacity_changed(self, value): + self.model.set_layer_opacity(value / 100.0) + self.opacity_label.setText(f"{value}%") diff --git a/tools/texture-editor/editor/pixel_canvas.py b/tools/texture-editor/editor/pixel_canvas.py index b38e503..67f69a4 100644 --- a/tools/texture-editor/editor/pixel_canvas.py +++ b/tools/texture-editor/editor/pixel_canvas.py @@ -1,10 +1,13 @@ +from PIL import Image as PILImage from PyQt6.QtWidgets import QWidget from PyQt6.QtCore import Qt from PyQt6.QtGui import QPainter, QColor, QImage, QMouseEvent, QWheelEvent, QPen +from editor.tools import BrushTool + class PixelCanvas(QWidget): - """Grid-based pixel editor with zoom, pan, and brush size support.""" + """Grid-based pixel editor with zoom, pan, and tool delegation.""" MIN_ZOOM = 1.0 MAX_ZOOM = 64.0 @@ -13,34 +16,51 @@ def __init__(self, model, parent=None): super().__init__(parent) self.model = model self._cached_image = None - self._painting = False + self._cached_reference = None + self._ref_bytes = None self._panning = False self._last_pan_pos = None self._zoom = 0.0 # 0 = fit-to-widget, >0 = manual zoom level (pixels per cell) self._pan_x = 0.0 # pan offset in widget pixels self._pan_y = 0.0 + self._active_tool = BrushTool(model) self.setMinimumSize(256, 256) self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.model.face_updated.connect(self._on_face_updated) + self.model.reference_changed.connect(self._on_reference_changed) self._rebuild_cache() + def set_tool(self, tool): + self._active_tool = tool + self._previous_tool = None + self.update() + + def activate_one_shot(self, tool): + """Temporarily switch to a one-shot tool, reverting after one click.""" + self._previous_tool = self._active_tool + self._active_tool = tool + self.update() + def _on_face_updated(self, face): self._rebuild_cache() self.update() + def _on_reference_changed(self): + self._rebuild_cache() + self.update() + def _cell_size(self): """Return the current cell size in widget pixels.""" - img = self.model.get_image() if self._zoom > 0: return self._zoom # Fit to widget - fit = min(self.width(), self.height()) / max(img.width, img.height) + fit = min(self.width(), self.height()) / max(self.model.size, 1) return max(1.0, fit) def _rebuild_cache(self): - img = self.model.get_image() - w, h = img.width, img.height + composite = self.model.get_composite() + w, h = composite.width, composite.height cell = self._cell_size() cell_int = max(1, int(cell)) canvas_w = cell_int * w @@ -64,8 +84,8 @@ def _rebuild_cache(self): check_size, check_size, check_color, ) - # Scale up the texture image with nearest-neighbor and draw it on top - scaled = img.resize( + # Scale up the composite with nearest-neighbor and draw it on top + scaled = composite.resize( (canvas_w, canvas_h), resample=0, # 0 = NEAREST ) raw = scaled.tobytes("raw", "BGRA") @@ -86,12 +106,25 @@ def _rebuild_cache(self): painter.end() self._cached_image = qimg + # Reference image cache + if self.model.reference_image is not None: + ref = self.model.reference_image + scaled_ref = ref.resize((canvas_w, canvas_h), PILImage.Resampling.BILINEAR) + ref_raw = scaled_ref.tobytes("raw", "BGRA") + self._ref_bytes = ref_raw + self._cached_reference = QImage( + ref_raw, canvas_w, canvas_h, QImage.Format.Format_ARGB32, + ) + else: + self._cached_reference = None + self._ref_bytes = None + def _canvas_origin(self): """Top-left corner of the canvas in widget coordinates.""" - img = self.model.get_image() cell = max(1, int(self._cell_size())) - canvas_w = cell * img.width - canvas_h = cell * img.height + size = self.model.size + canvas_w = cell * size + canvas_h = cell * size ox = (self.width() - canvas_w) / 2.0 + self._pan_x oy = (self.height() - canvas_h) / 2.0 + self._pan_y return ox, oy @@ -104,16 +137,15 @@ def paintEvent(self, event): ox, oy = self._canvas_origin() painter.drawImage(int(ox), int(oy), self._cached_image) - # Draw brush cursor - if self.underMouse() and not self._panning: - pos = self.mapFromGlobal(self.cursor().pos()) - px, py = self._pixel_at(pos) - brush = self.model.brush_size - cell = max(1, int(self._cell_size())) - cx = int(ox + (px - brush // 2) * cell) - cy = int(oy + (py - brush // 2) * cell) - painter.setPen(QColor(255, 255, 255, 160)) - painter.drawRect(cx, cy, brush * cell, brush * cell) + # Reference image overlay + if self._cached_reference is not None: + painter.setOpacity(self.model.reference_opacity) + painter.drawImage(int(ox), int(oy), self._cached_reference) + painter.setOpacity(1.0) + + # Delegate overlay drawing to the active tool + if not self._panning: + self._active_tool.draw_overlay(painter, self) painter.end() @@ -128,12 +160,8 @@ def _pixel_at(self, pos): y = int((pos.y() - oy) / cell) return x, y - def _paint_at(self, pos): - """Paint a brush_size square centered on the pixel under pos.""" - cx, cy = self._pixel_at(pos) - self.model.paint_brush(cx, cy, self.model.brush_size) - def mousePressEvent(self, event: QMouseEvent): + # Middle button: pan (universal) if event.button() == Qt.MouseButton.MiddleButton: self._panning = True self._last_pan_pos = event.position() @@ -141,16 +169,23 @@ def mousePressEvent(self, event: QMouseEvent): return pos = event.position().toPoint() - if event.button() == Qt.MouseButton.LeftButton: - self.model.begin_stroke() - self._paint_at(pos) - self._painting = True - elif event.button() == Qt.MouseButton.RightButton: + + # Right button: eyedropper (universal) + if event.button() == Qt.MouseButton.RightButton: x, y = self._pixel_at(pos) color = self.model.get_pixel(x, y) if color: self.model.current_color = tuple(color) self.model.color_changed.emit() + return + + # Left button: delegate to active tool + x, y = self._pixel_at(pos) + handled = self._active_tool.on_press(x, y, event.button(), event.modifiers()) + if handled and self._active_tool.one_shot and self._previous_tool: + self._active_tool = self._previous_tool + self._previous_tool = None + self.update() def mouseMoveEvent(self, event: QMouseEvent): if self._panning and self._last_pan_pos is not None: @@ -162,10 +197,10 @@ def mouseMoveEvent(self, event: QMouseEvent): self.update() return - if self._painting: - self._paint_at(event.position().toPoint()) + x, y = self._pixel_at(event.position().toPoint()) + self._active_tool.on_move(x, y, event.buttons(), event.modifiers()) - # Update brush cursor position + # Repaint for cursor / overlay updates self.update() def mouseReleaseEvent(self, event: QMouseEvent): @@ -175,9 +210,14 @@ def mouseReleaseEvent(self, event: QMouseEvent): self.setCursor(Qt.CursorShape.ArrowCursor) return - if event.button() == Qt.MouseButton.LeftButton and self._painting: - self._painting = False - self.model.end_stroke() + x, y = self._pixel_at(event.position().toPoint()) + self._active_tool.on_release(x, y, event.button(), event.modifiers()) + + def keyPressEvent(self, event): + if self._active_tool.on_key_press(event.key(), event.modifiers()): + self.update() + return + super().keyPressEvent(event) def wheelEvent(self, event: QWheelEvent): delta = event.angleDelta().y() @@ -202,9 +242,9 @@ def wheelEvent(self, event: QWheelEvent): new_cell = self._cell_size() # Adjust pan so the pixel under the cursor stays in place - img = self.model.get_image() - new_canvas_w = int(new_cell) * img.width - new_canvas_h = int(new_cell) * img.height + size = self.model.size + new_canvas_w = int(new_cell) * size + new_canvas_h = int(new_cell) * size new_center_ox = (self.width() - new_canvas_w) / 2.0 new_center_oy = (self.height() - new_canvas_h) / 2.0 target_ox = mouse_pos.x() - px_before * new_cell diff --git a/tools/texture-editor/editor/preview_3d.py b/tools/texture-editor/editor/preview_3d.py index 84ad920..3e33bb0 100644 --- a/tools/texture-editor/editor/preview_3d.py +++ b/tools/texture-editor/editor/preview_3d.py @@ -4,7 +4,7 @@ from PyQt6.QtGui import QMouseEvent, QWheelEvent from OpenGL.GL import ( - glClearColor, glClear, glEnable, + glClearColor, glClear, glEnable, glDisable, GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT, GL_DEPTH_TEST, GL_TEXTURE_2D, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, glBlendFunc, glDepthMask, GL_FALSE, GL_TRUE, @@ -54,7 +54,7 @@ def _pil_to_gl_bytes(self, img): def _upload_all_textures(self): for face in FACE_NAMES: - img = self.model.get_image(face) + img = self.model.get_composite(face) tex_id = glGenTextures(1) glBindTexture(GL_TEXTURE_2D, tex_id) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) @@ -71,7 +71,7 @@ def _upload_all_textures(self): def _on_face_updated(self, face): self.makeCurrent() - img = self.model.get_image(face) + img = self.model.get_composite(face) tex_id = self._textures.get(face) if tex_id is not None: glBindTexture(GL_TEXTURE_2D, tex_id) @@ -87,7 +87,7 @@ def _on_face_updated(self, face): def _on_state_changed(self, index): self.makeCurrent() for face in FACE_NAMES: - img = self.model.get_image(face) + img = self.model.get_composite(face) tex_id = self._textures.get(face) if tex_id is not None: glBindTexture(GL_TEXTURE_2D, tex_id) @@ -110,11 +110,10 @@ def resizeGL(self, w, h): def _face_has_transparency(self, tex_key): """Check if a face texture contains any transparent pixels.""" - img = self.model.get_image(tex_key) + img = self.model.get_composite(tex_key) if img.mode != "RGBA": return False extrema = img.getextrema() - # extrema[3] is (min_alpha, max_alpha) return extrema[3][0] < 255 def _draw_face(self, face_data): @@ -156,12 +155,12 @@ def paintGL(self): else: opaque.append(face_data) - # Pass 1: opaque faces with depth write on + # Pass 1: opaque faces glDepthMask(GL_TRUE) for face_data in opaque: self._draw_face(face_data) - # Pass 2: transparent faces with depth write off + # Pass 2: transparent faces if transparent: glDepthMask(GL_FALSE) for face_data in transparent: diff --git a/tools/texture-editor/editor/texture_model.py b/tools/texture-editor/editor/texture_model.py index d67d185..b6de5e1 100644 --- a/tools/texture-editor/editor/texture_model.py +++ b/tools/texture-editor/editor/texture_model.py @@ -1,10 +1,17 @@ import json import os +from collections import deque from PIL import Image from PyQt6.QtCore import QObject, pyqtSignal FACE_NAMES = ["north", "south", "east", "west", "up", "down"] +SYMMETRY_NONE = "none" +SYMMETRY_HORIZONTAL = "horizontal" +SYMMETRY_VERTICAL = "vertical" +SYMMETRY_QUAD = "quad" +SYMMETRY_MODES = [SYMMETRY_NONE, SYMMETRY_HORIZONTAL, SYMMETRY_VERTICAL, SYMMETRY_QUAD] + # Maps CraftEngine parent model texture keys to face names PARENT_MODEL_MAPPINGS = { "cube_bottom_top": { @@ -118,7 +125,6 @@ def parse_model_json(model_ref): from_pos = element["from"] to_pos = element["to"] - # Normalize from 0-16 Minecraft coords to -0.5..0.5 GL coords x1 = from_pos[0] / 16.0 - 0.5 y1 = from_pos[1] / 16.0 - 0.5 z1 = from_pos[2] / 16.0 - 0.5 @@ -128,17 +134,14 @@ def parse_model_json(model_ref): faces = {} for face_dir, face_data in element.get("faces", {}).items(): - # Resolve texture variable name → texture key tex_ref = face_data.get("texture", "") tex_key = tex_ref.lstrip("#") if tex_ref.startswith("#") else face_dir - # UV: explicit or default to full texture if "uv" in face_data: mu1, mv1, mu2, mv2 = face_data["uv"] else: mu1, mv1, mu2, mv2 = 0, 0, 16, 16 - # Normalize UV to 0..1, flip v (Minecraft is top-down, GL is bottom-up) nu1 = mu1 / 16.0 nu2 = mu2 / 16.0 gl_v_bottom = 1.0 - mv2 / 16.0 @@ -148,35 +151,40 @@ def parse_model_json(model_ref): (nu2, gl_v_top), (nu1, gl_v_top), ] - # Vertices: 4 corners of the quad (BL, BR, TR, TL from outside) if face_dir == "north": verts = [ - (x1, y1, z1), (x2, y1, z1), (x2, y2, z1), (x1, y2, z1), + (x1, y1, z1), (x2, y1, z1), + (x2, y2, z1), (x1, y2, z1), ] normal = (0, 0, -1) elif face_dir == "south": verts = [ - (x2, y1, z2), (x1, y1, z2), (x1, y2, z2), (x2, y2, z2), + (x2, y1, z2), (x1, y1, z2), + (x1, y2, z2), (x2, y2, z2), ] normal = (0, 0, 1) elif face_dir == "east": verts = [ - (x2, y1, z1), (x2, y1, z2), (x2, y2, z2), (x2, y2, z1), + (x2, y1, z1), (x2, y1, z2), + (x2, y2, z2), (x2, y2, z1), ] normal = (1, 0, 0) elif face_dir == "west": verts = [ - (x1, y1, z2), (x1, y1, z1), (x1, y2, z1), (x1, y2, z2), + (x1, y1, z2), (x1, y1, z1), + (x1, y2, z1), (x1, y2, z2), ] normal = (-1, 0, 0) elif face_dir == "up": verts = [ - (x1, y2, z1), (x2, y2, z1), (x2, y2, z2), (x1, y2, z2), + (x1, y2, z1), (x2, y2, z1), + (x2, y2, z2), (x1, y2, z2), ] normal = (0, 1, 0) elif face_dir == "down": verts = [ - (x1, y1, z2), (x2, y1, z2), (x2, y1, z1), (x1, y1, z1), + (x1, y1, z2), (x2, y1, z2), + (x2, y1, z1), (x1, y1, z1), ] normal = (0, -1, 0) else: @@ -194,23 +202,51 @@ def parse_model_json(model_ref): return geometry +class Layer: + """A single layer within a face texture.""" + + def __init__(self, name, size, fill_color=(0, 0, 0, 0)): + self.name = name + self.image = Image.new("RGBA", (size, size), fill_color) + self.opacity = 1.0 + self.visible = True + + +class UndoEntry: + """Delta-based undo entry storing only the changed region.""" + + __slots__ = ('state_idx', 'face', 'layer_idx', 'bbox', 'region') + + def __init__(self, state_idx, face, layer_idx, bbox, region): + self.state_idx = state_idx + self.face = face + self.layer_idx = layer_idx + self.bbox = bbox + self.region = region + + class BlockState: - """A single block state containing 6 face textures and optional geometry.""" + """A single block state containing layered face textures and optional geometry.""" def __init__(self, name, size=32): self.name = name - self.geometry = None # None = default cube, otherwise parsed model - self.faces = {} + self.geometry = None + self.layers = {} # face -> [Layer, ...] + self.active_layer = {} # face -> int (index into layers list) + self.source_paths = {} # face -> original file path for face in FACE_NAMES: - self.faces[face] = Image.new("RGBA", (size, size), (200, 200, 200, 255)) + self.layers[face] = [Layer("Background", size, (200, 200, 200, 255))] + self.active_layer[face] = 0 class TextureModel(QObject): - """Shared data model holding multi-state block textures.""" + """Shared data model holding multi-state block textures with layer support.""" face_updated = pyqtSignal(str) state_changed = pyqtSignal(int) color_changed = pyqtSignal() + layers_changed = pyqtSignal() + reference_changed = pyqtSignal() def __init__(self, size=32): super().__init__() @@ -220,9 +256,17 @@ def __init__(self, size=32): self.active_face = "north" self.current_color = (0, 0, 0, 255) self.brush_size = 1 + self.symmetry = SYMMETRY_NONE + self.selection_rect = None # (x, y, w, h) or None — set by SelectionTool + self.reference_image = None # PIL Image or None + self.reference_opacity = 0.3 self._undo_stack = [] self._redo_stack = [] self._stroke_snapshot = None + self._stroke_state = None + self._stroke_face = None + self._stroke_layer = None + self._stroke_bbox = None # (x1, y1, x2, y2) dirty region tracked during painting @property def active_state(self): @@ -234,116 +278,421 @@ def get_geometry(self): return geo if geo is not None else DEFAULT_GEOMETRY def get_image(self, face=None, state_index=None): + """Return the active layer's image for direct editing.""" if state_index is None: state_index = self.active_state_index if face is None: face = self.active_face - return self.states[state_index].faces[face] + state = self.states[state_index] + idx = state.active_layer[face] + return state.layers[face][idx].image + + def get_composite(self, face=None, state_index=None): + """Return the flattened composite of all visible layers for a face.""" + if state_index is None: + state_index = self.active_state_index + if face is None: + face = self.active_face + state = self.states[state_index] + layers = state.layers[face] + + # Fast path: single visible layer at full opacity + visible = [layer for layer in layers if layer.visible] + if len(visible) == 1 and visible[0].opacity >= 1.0: + return visible[0].image + + result = Image.new("RGBA", (self.size, self.size), (0, 0, 0, 0)) + for layer in layers: + if not layer.visible: + continue + if layer.opacity >= 1.0: + result = Image.alpha_composite(result, layer.image) + else: + temp = layer.image.copy() + bands = temp.split() + alpha = bands[3].point(lambda x, o=layer.opacity: int(x * o)) + temp = Image.merge("RGBA", (bands[0], bands[1], bands[2], alpha)) + result = Image.alpha_composite(result, temp) + return result def set_active_face(self, face): if face in FACE_NAMES: self.active_face = face self.face_updated.emit(face) + self.layers_changed.emit() def set_active_state(self, index): if 0 <= index < len(self.states): self.active_state_index = index self.state_changed.emit(index) self.face_updated.emit(self.active_face) + self.layers_changed.emit() def add_state(self, name): self.states.append(BlockState(name, self.size)) def resize_all(self, new_size): - """Resize all face textures across all states to a new size.""" + """Resize all layer textures across all states to a new size.""" self.size = new_size for state in self.states: for face in FACE_NAMES: - old_img = state.faces[face] - if old_img.width == new_size and old_img.height == new_size: - continue - state.faces[face] = old_img.resize( - (new_size, new_size), Image.Resampling.NEAREST, - ) + for layer in state.layers[face]: + if layer.image.width == new_size and layer.image.height == new_size: + continue + layer.image = layer.image.resize( + (new_size, new_size), Image.Resampling.NEAREST, + ) self._undo_stack.clear() self._redo_stack.clear() self.state_changed.emit(self.active_state_index) self.face_updated.emit(self.active_face) + self.layers_changed.emit() + + # ------------------------------------------------------------------ + # Layer management + # ------------------------------------------------------------------ + + def set_active_layer(self, index): + state = self.active_state + face = self.active_face + if 0 <= index < len(state.layers[face]): + state.active_layer[face] = index + self.layers_changed.emit() + + def add_layer(self, name=None): + state = self.active_state + face = self.active_face + if name is None: + name = f"Layer {len(state.layers[face])}" + idx = state.active_layer[face] + 1 + new_layer = Layer(name, self.size) + state.layers[face].insert(idx, new_layer) + state.active_layer[face] = idx + self.layers_changed.emit() + self.face_updated.emit(face) + + def delete_layer(self): + state = self.active_state + face = self.active_face + layers = state.layers[face] + if len(layers) <= 1: + return + idx = state.active_layer[face] + layers.pop(idx) + state.active_layer[face] = min(idx, len(layers) - 1) + self._undo_stack.clear() + self._redo_stack.clear() + self.layers_changed.emit() + self.face_updated.emit(face) + + def merge_layer_down(self): + state = self.active_state + face = self.active_face + layers = state.layers[face] + idx = state.active_layer[face] + if idx <= 0: + return + upper = layers[idx] + lower = layers[idx - 1] + if upper.opacity >= 1.0: + lower.image = Image.alpha_composite(lower.image, upper.image) + else: + temp = upper.image.copy() + bands = temp.split() + alpha = bands[3].point(lambda x, o=upper.opacity: int(x * o)) + temp = Image.merge("RGBA", (bands[0], bands[1], bands[2], alpha)) + lower.image = Image.alpha_composite(lower.image, temp) + layers.pop(idx) + state.active_layer[face] = idx - 1 + self._undo_stack.clear() + self._redo_stack.clear() + self.layers_changed.emit() + self.face_updated.emit(face) + + def move_layer(self, direction): + """Move active layer up (+1) or down (-1).""" + state = self.active_state + face = self.active_face + layers = state.layers[face] + idx = state.active_layer[face] + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(layers): + return + layers[idx], layers[new_idx] = layers[new_idx], layers[idx] + state.active_layer[face] = new_idx + self.layers_changed.emit() + self.face_updated.emit(face) + + def set_layer_opacity(self, opacity): + state = self.active_state + face = self.active_face + idx = state.active_layer[face] + layers = state.layers[face] + if 0 <= idx < len(layers): + layers[idx].opacity = opacity + self.face_updated.emit(face) + self.layers_changed.emit() + + def set_layer_visible(self, layer_idx, visible): + state = self.active_state + face = self.active_face + layers = state.layers[face] + if 0 <= layer_idx < len(layers): + layers[layer_idx].visible = visible + self.face_updated.emit(face) + self.layers_changed.emit() + + # ------------------------------------------------------------------ + # Delta-based undo / redo + # ------------------------------------------------------------------ def begin_stroke(self): """Save a snapshot before a paint stroke begins.""" self._stroke_snapshot = self.get_image().copy() + self._stroke_state = self.active_state_index + self._stroke_face = self.active_face + self._stroke_layer = self.active_state.active_layer[self.active_face] + self._stroke_bbox = None + + def _expand_stroke_bbox(self, x, y): + """Expand the dirty region to include pixel (x, y).""" + if self._stroke_bbox is None: + self._stroke_bbox = (x, y, x + 1, y + 1) + else: + x1, y1, x2, y2 = self._stroke_bbox + self._stroke_bbox = (min(x1, x), min(y1, y), max(x2, x + 1), max(y2, y + 1)) def end_stroke(self): - """Finalize a paint stroke, pushing the snapshot to undo stack.""" - if self._stroke_snapshot is not None: - self._undo_stack.append(( - self.active_state_index, - self.active_face, - self._stroke_snapshot, - )) - if len(self._undo_stack) > 20: - self._undo_stack.pop(0) - self._redo_stack.clear() + """Finalize a paint stroke, storing only the changed region.""" + if self._stroke_snapshot is None: self._stroke_snapshot = None + return + bbox = self._stroke_bbox + if bbox is None: + self._stroke_snapshot = None + return + old_region = self._stroke_snapshot.crop(bbox) + self._undo_stack.append(UndoEntry( + self._stroke_state, self._stroke_face, + self._stroke_layer, bbox, old_region, + )) + if len(self._undo_stack) > 100: + self._undo_stack.pop(0) + self._redo_stack.clear() + self._stroke_snapshot = None + self._stroke_bbox = None def undo(self): if not self._undo_stack: return - state_idx, face, snapshot = self._undo_stack.pop() - current = self.states[state_idx].faces[face].copy() - self._redo_stack.append((state_idx, face, current)) - self.states[state_idx].faces[face] = snapshot - if state_idx == self.active_state_index: - self.face_updated.emit(face) + entry = self._undo_stack.pop() + layer = self.states[entry.state_idx].layers[entry.face][entry.layer_idx] + current_region = layer.image.crop(entry.bbox) + self._redo_stack.append(UndoEntry( + entry.state_idx, entry.face, entry.layer_idx, + entry.bbox, current_region, + )) + layer.image.paste(entry.region, (entry.bbox[0], entry.bbox[1])) + if entry.state_idx == self.active_state_index: + self.face_updated.emit(entry.face) def redo(self): if not self._redo_stack: return - state_idx, face, snapshot = self._redo_stack.pop() - current = self.states[state_idx].faces[face].copy() - self._undo_stack.append((state_idx, face, current)) - self.states[state_idx].faces[face] = snapshot - if state_idx == self.active_state_index: - self.face_updated.emit(face) + entry = self._redo_stack.pop() + layer = self.states[entry.state_idx].layers[entry.face][entry.layer_idx] + current_region = layer.image.crop(entry.bbox) + self._undo_stack.append(UndoEntry( + entry.state_idx, entry.face, entry.layer_idx, + entry.bbox, current_region, + )) + layer.image.paste(entry.region, (entry.bbox[0], entry.bbox[1])) + if entry.state_idx == self.active_state_index: + self.face_updated.emit(entry.face) + + # ------------------------------------------------------------------ + # Symmetry helpers + # ------------------------------------------------------------------ + + def _mirror_points(self, cx, cy): + """Return list of mirrored (cx, cy) points based on symmetry mode.""" + img = self.get_image() + w, h = img.width, img.height + points = [(cx, cy)] + if self.symmetry == SYMMETRY_HORIZONTAL: + points.append((w - 1 - cx, cy)) + elif self.symmetry == SYMMETRY_VERTICAL: + points.append((cx, h - 1 - cy)) + elif self.symmetry == SYMMETRY_QUAD: + points.append((w - 1 - cx, cy)) + points.append((cx, h - 1 - cy)) + points.append((w - 1 - cx, h - 1 - cy)) + return points + + def _mirror_pair(self, x0, y0, x1, y1): + """Return mirrored (start, end) point pairs for shape symmetry.""" + img = self.get_image() + w, h = img.width, img.height + pairs = [(x0, y0, x1, y1)] + if self.symmetry == SYMMETRY_HORIZONTAL: + pairs.append((w - 1 - x0, y0, w - 1 - x1, y1)) + elif self.symmetry == SYMMETRY_VERTICAL: + pairs.append((x0, h - 1 - y0, x1, h - 1 - y1)) + elif self.symmetry == SYMMETRY_QUAD: + pairs.append((w - 1 - x0, y0, w - 1 - x1, y1)) + pairs.append((x0, h - 1 - y0, x1, h - 1 - y1)) + pairs.append((w - 1 - x0, h - 1 - y0, w - 1 - x1, h - 1 - y1)) + return pairs + + # ------------------------------------------------------------------ + # Pixel operations + # ------------------------------------------------------------------ def set_pixel(self, x, y, emit=True): img = self.get_image() if 0 <= x < img.width and 0 <= y < img.height: img.putpixel((x, y), self.current_color) + self._expand_stroke_bbox(x, y) if emit: self.face_updated.emit(self.active_face) def paint_brush(self, cx, cy, brush_size): - """Paint a brush_size square centered on (cx, cy). Emits once.""" + """Paint a brush_size square centered on (cx, cy) with symmetry.""" img = self.get_image() + w, h = img.width, img.height offset = brush_size // 2 changed = False - for dy in range(brush_size): - for dx in range(brush_size): - px = cx - offset + dx - py = cy - offset + dy - if 0 <= px < img.width and 0 <= py < img.height: - img.putpixel((px, py), self.current_color) - changed = True + for mcx, mcy in self._mirror_points(cx, cy): + for dy in range(brush_size): + for dx in range(brush_size): + px = mcx - offset + dx + py = mcy - offset + dy + if 0 <= px < w and 0 <= py < h: + img.putpixel((px, py), self.current_color) + self._expand_stroke_bbox(px, py) + changed = True if changed: self.face_updated.emit(self.active_face) - def get_pixel(self, x, y): + def flood_fill(self, x, y): + """Flood fill starting at (x, y) with the current color.""" img = self.get_image() + w, h = img.width, img.height + if not (0 <= x < w and 0 <= y < h): + return + + target_color = img.getpixel((x, y)) + fill_color = self.current_color + if target_color == fill_color: + return + + # Snapshot for undo + self.begin_stroke() + + pixels = img.load() + queue = deque() + queue.append((x, y)) + visited = set() + visited.add((x, y)) + + while queue: + px, py = queue.popleft() + pixels[px, py] = fill_color + self._expand_stroke_bbox(px, py) + for nx, ny in ((px - 1, py), (px + 1, py), (px, py - 1), (px, py + 1)): + if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in visited: + if pixels[nx, ny] == target_color: + visited.add((nx, ny)) + queue.append((nx, ny)) + + self.face_updated.emit(self.active_face) + self.end_stroke() + + def copy_face(self, src_face, dst_face): + """Copy all layers from one face to another within the active state.""" + if src_face == dst_face: + return + state = self.active_state + src_layers = state.layers[src_face] + new_layers = [] + for src_l in src_layers: + dst_l = Layer(src_l.name, self.size) + dst_l.image = src_l.image.copy() + dst_l.opacity = src_l.opacity + dst_l.visible = src_l.visible + new_layers.append(dst_l) + state.layers[dst_face] = new_layers + state.active_layer[dst_face] = state.active_layer[src_face] + self.face_updated.emit(dst_face) + self.layers_changed.emit() + + def get_pixel(self, x, y): + """Sample a pixel from the composite (what the user sees).""" + img = self.get_composite() if 0 <= x < img.width and 0 <= y < img.height: return img.getpixel((x, y)) return None + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + def load_face(self, face, filepath): img = Image.open(filepath).convert("RGBA") - self.active_state.faces[face] = img + state = self.active_state + state.layers[face] = [Layer("Background", img.width)] + state.layers[face][0].image = img + state.active_layer[face] = 0 + state.source_paths[face] = filepath if self.size != img.width: self.size = img.width + self._undo_stack.clear() + self._redo_stack.clear() self.face_updated.emit(face) + self.layers_changed.emit() def save_face(self, face, filepath): - self.active_state.faces[face].save(filepath, "PNG") + self.get_composite(face).save(filepath, "PNG") + + def save_to_source_paths(self): + """Save all faces across all states back to their original file paths. + + Returns (saved_count, skipped_faces) tuple. + """ + saved = 0 + skipped = [] + for si, state in enumerate(self.states): + for face in FACE_NAMES: + path = state.source_paths.get(face) + if path: + self.get_composite(face, si).save(path, "PNG") + saved += 1 + else: + skipped.append(f"{state.name}/{face}") + return saved, skipped + + def has_source_paths(self): + """Check if any state has source paths from loading.""" + return any( + bool(state.source_paths) for state in self.states + ) + + # ------------------------------------------------------------------ + # Reference image + # ------------------------------------------------------------------ + + def set_reference(self, image): + """Set a reference image (PIL Image) or None to clear.""" + self.reference_image = image + self.reference_changed.emit() + + def set_reference_opacity(self, opacity): + self.reference_opacity = max(0.0, min(1.0, opacity)) + self.reference_changed.emit() + + # ------------------------------------------------------------------ + # Block loading + # ------------------------------------------------------------------ def _load_state_from_generation(self, state_name, generation, texture_dir): """Build a BlockState from a CraftEngine generation block.""" @@ -356,8 +705,6 @@ def _load_state_from_generation(self, state_name, generation, texture_dir): parent_type = ptype break - # If no known parent matched but textures use standard face keys, - # treat it as a cube-style mapping (e.g. custom parent models) if not parent_type and textures: cube_keys = set(PARENT_MODEL_MAPPINGS["cube"].keys()) if any(k in cube_keys for k in textures): @@ -382,9 +729,11 @@ def _load_state_from_generation(self, state_name, generation, texture_dir): if isinstance(target_faces, str): target_faces = [target_faces] for face in target_faces: - block_state.faces[face] = img.copy() + block_state.layers[face] = [Layer("Background", img.width)] + block_state.layers[face][0].image = img.copy() + block_state.active_layer[face] = 0 + block_state.source_paths[face] = filepath - # Try parsing the parent as a custom model JSON for geometry if parent_type == "cube" and parent: parsed = parse_model_json(parent) if parsed: @@ -402,16 +751,16 @@ def load_block_from_yaml(self, yaml_data, texture_dir): if key.startswith("items"): sections.append((key, yaml_data[key])) - # Sort so 'items' comes first, then 'items#1', 'items#2', etc. sections.sort(key=lambda x: (x[0] != "items", x[0])) for section_key, section_data in sections: for item_id, item_config in section_data.items(): - state_name = item_id.split(":")[-1] if ":" in item_id else item_id + state_name = ( + item_id.split(":")[-1] if ":" in item_id else item_id + ) behavior = item_config.get("behavior", {}) block = behavior.get("block", {}) - # Format 1: simple single-state — state.model.generation state_section = block.get("state", {}) model = state_section.get("model", {}) generation = model.get("generation", {}) @@ -423,7 +772,6 @@ def load_block_from_yaml(self, yaml_data, texture_dir): self.states.append(bs) continue - # Format 2: directional / multi-appearance — states.appearances states_section = block.get("states", {}) appearances = states_section.get("appearances", {}) if appearances: @@ -444,8 +792,11 @@ def load_block_from_yaml(self, yaml_data, texture_dir): self.active_state_index = 0 self.active_face = "north" + self._undo_stack.clear() + self._redo_stack.clear() self.state_changed.emit(0) self.face_updated.emit(self.active_face) + self.layers_changed.emit() def load_block_by_filename(self, block_id, texture_dir): """Fallback: discover textures by filename pattern.""" @@ -455,7 +806,6 @@ def load_block_by_filename(self, block_id, texture_dir): block_state = BlockState(block_id, self.size) loaded_any = False - # Try cube_bottom_top layout: {id}_top, {id}_bottom, {id}_side top_path = os.path.join(texture_dir, f"{block_id}_top.png") bottom_path = os.path.join(texture_dir, f"{block_id}_bottom.png") side_path = os.path.join(texture_dir, f"{block_id}_side.png") @@ -467,13 +817,18 @@ def load_block_by_filename(self, block_id, texture_dir): side_img = Image.open(side_path).convert("RGBA") self.size = top_img.width block_state = BlockState(block_id, self.size) - block_state.faces["up"] = top_img - block_state.faces["down"] = bottom_img + block_state.layers["up"] = [Layer("Background", self.size)] + block_state.layers["up"][0].image = top_img + block_state.source_paths["up"] = top_path + block_state.layers["down"] = [Layer("Background", self.size)] + block_state.layers["down"][0].image = bottom_img + block_state.source_paths["down"] = bottom_path for face in ["north", "south", "east", "west"]: - block_state.faces[face] = side_img.copy() + block_state.layers[face] = [Layer("Background", self.size)] + block_state.layers[face][0].image = side_img.copy() + block_state.source_paths[face] = side_path loaded_any = True else: - # Try cube layout: {id}_north, {id}_south, etc. face_map = {} for face in FACE_NAMES: path = os.path.join(texture_dir, f"{block_id}_{face}.png") @@ -481,21 +836,29 @@ def load_block_by_filename(self, block_id, texture_dir): face_map[face] = path if face_map: - first_img = Image.open(list(face_map.values())[0]).convert("RGBA") + first_img = Image.open( + list(face_map.values())[0], + ).convert("RGBA") self.size = first_img.width block_state = BlockState(block_id, self.size) for face, path in face_map.items(): - block_state.faces[face] = Image.open(path).convert("RGBA") + img = Image.open(path).convert("RGBA") + block_state.layers[face] = [Layer("Background", self.size)] + block_state.layers[face][0].image = img + block_state.active_layer[face] = 0 + block_state.source_paths[face] = path loaded_any = True else: - # Try cube_all layout: just {id}.png all_path = os.path.join(texture_dir, f"{block_id}.png") if os.path.exists(all_path): img = Image.open(all_path).convert("RGBA") self.size = img.width block_state = BlockState(block_id, self.size) for face in FACE_NAMES: - block_state.faces[face] = img.copy() + block_state.layers[face] = [Layer("Background", self.size)] + block_state.layers[face][0].image = img.copy() + block_state.active_layer[face] = 0 + block_state.source_paths[face] = all_path loaded_any = True self.states.append(block_state) @@ -504,6 +867,9 @@ def load_block_by_filename(self, block_id, texture_dir): self.active_state_index = 0 self.active_face = "north" + self._undo_stack.clear() + self._redo_stack.clear() self.state_changed.emit(0) self.face_updated.emit(self.active_face) + self.layers_changed.emit() return loaded_any diff --git a/tools/texture-editor/editor/tile_preview.py b/tools/texture-editor/editor/tile_preview.py new file mode 100644 index 0000000..287edfa --- /dev/null +++ b/tools/texture-editor/editor/tile_preview.py @@ -0,0 +1,57 @@ +"""Tiling preview widget — displays the current face texture in a 3x3 grid.""" + +from PIL import Image +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter, QImage + + +class TilePreview(QWidget): + """Displays the active face texture tiled 3x3 for seamless-tiling inspection.""" + + TILE_COUNT = 3 + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self.setMinimumSize(128, 128) + self._cached_qimage = None + self._cached_bytes = None + self.model.face_updated.connect(self._on_face_updated) + + def _on_face_updated(self, face): + self._cached_qimage = None + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.black) + + img = self.model.get_composite() + tw, th = img.width, img.height + total_w = tw * self.TILE_COUNT + total_h = th * self.TILE_COUNT + + # Build 3x3 tiled PIL image + tiled = Image.new("RGBA", (total_w, total_h)) + for ty in range(self.TILE_COUNT): + for tx in range(self.TILE_COUNT): + tiled.paste(img, (tx * tw, ty * th)) + + # Convert to QImage + raw = tiled.tobytes("raw", "BGRA") + self._cached_bytes = raw + qimg = QImage(raw, total_w, total_h, QImage.Format.Format_ARGB32) + + # Scale to fit widget while keeping aspect ratio + widget_w, widget_h = self.width(), self.height() + scale = min(widget_w / total_w, widget_h / total_h) + scaled_w = int(total_w * scale) + scaled_h = int(total_h * scale) + scaled = qimg.scaled(scaled_w, scaled_h, transformMode=Qt.TransformationMode.FastTransformation) + + # Draw centered + dx = (widget_w - scaled_w) // 2 + dy = (widget_h - scaled_h) // 2 + painter.drawImage(dx, dy, scaled) + painter.end() diff --git a/tools/texture-editor/editor/tools.py b/tools/texture-editor/editor/tools.py new file mode 100644 index 0000000..8405949 --- /dev/null +++ b/tools/texture-editor/editor/tools.py @@ -0,0 +1,705 @@ +"""Tool system for the pixel canvas — brush, shapes, gradient, selection.""" + +import math +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor, QPen + + +class Tool: + """Base class for all canvas tools.""" + + name = "tool" + one_shot = False + + def __init__(self, model): + self.model = model + + def on_press(self, x, y, button, modifiers): + """Return True if handled.""" + return False + + def on_move(self, x, y, buttons, modifiers): + pass + + def on_release(self, x, y, button, modifiers): + pass + + def on_key_press(self, key, modifiers): + """Return True if handled.""" + return False + + def draw_overlay(self, painter, canvas): + """Draw tool overlay. painter is QPainter in widget coords.""" + pass + + def cursor(self): + return Qt.CursorShape.CrossCursor + + +class BrushTool(Tool): + """Standard brush tool with optional shift+click flood fill shortcut.""" + + name = "Brush" + + def __init__(self, model): + super().__init__(model) + self._painting = False + + def on_press(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return False + if modifiers & Qt.KeyboardModifier.ShiftModifier: + self.model.flood_fill(x, y) + return True + self.model.begin_stroke() + self.model.paint_brush(x, y, self.model.brush_size) + self._painting = True + return True + + def on_move(self, x, y, buttons, modifiers): + if self._painting: + self.model.paint_brush(x, y, self.model.brush_size) + + def on_release(self, x, y, button, modifiers): + if button == Qt.MouseButton.LeftButton and self._painting: + self._painting = False + self.model.end_stroke() + + def draw_overlay(self, painter, canvas): + if not canvas.underMouse(): + return + pos = canvas.mapFromGlobal(canvas.cursor().pos()) + px, py = canvas._pixel_at(pos) + brush = self.model.brush_size + cell = max(1, int(canvas._cell_size())) + ox, oy = canvas._canvas_origin() + cx = int(ox + (px - brush // 2) * cell) + cy = int(oy + (py - brush // 2) * cell) + painter.setPen(QColor(255, 255, 255, 160)) + painter.drawRect(cx, cy, brush * cell, brush * cell) + + +class FillTool(Tool): + """Flood fill — one-shot tool that reverts after a single click.""" + + name = "Fill" + one_shot = True + + def on_press(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return False + self.model.flood_fill(x, y) + return True + + def draw_overlay(self, painter, canvas): + if not canvas.underMouse(): + return + pos = canvas.mapFromGlobal(canvas.cursor().pos()) + px, py = canvas._pixel_at(pos) + cell = max(1, int(canvas._cell_size())) + ox, oy = canvas._canvas_origin() + cx = int(ox + px * cell) + cy = int(oy + py * cell) + pen = QPen(QColor(255, 255, 255, 160)) + pen.setWidth(2) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(cx + 2, cy + 2, cell - 4, cell - 4) + + +class EyedropperTool(Tool): + """Color sampler — one-shot tool that reverts after a single click.""" + + name = "Eyedropper" + one_shot = True + + def on_press(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return False + color = self.model.get_pixel(x, y) + if color: + self.model.current_color = tuple(color) + self.model.color_changed.emit() + return True + + def draw_overlay(self, painter, canvas): + if not canvas.underMouse(): + return + pos = canvas.mapFromGlobal(canvas.cursor().pos()) + px, py = canvas._pixel_at(pos) + cell = max(1, int(canvas._cell_size())) + ox, oy = canvas._canvas_origin() + cx = int(ox + px * cell) + cy = int(oy + py * cell) + painter.setPen(QColor(255, 255, 255, 200)) + mid_x = cx + cell // 2 + mid_y = cy + cell // 2 + painter.drawLine(mid_x - cell, mid_y, mid_x + cell, mid_y) + painter.drawLine(mid_x, mid_y - cell, mid_x, mid_y + cell) + + +# --------------------------------------------------------------------------- +# Drag-based tools (shapes, gradient) +# --------------------------------------------------------------------------- + +class DragShapeTool(Tool): + """Base for tools that drag from start to end then commit.""" + + def __init__(self, model): + super().__init__(model) + self._start = None + self._end = None + self._dragging = False + self.filled = False + + def on_press(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return False + self._start = (x, y) + self._end = (x, y) + self._dragging = True + self.model.begin_stroke() + return True + + def on_move(self, x, y, buttons, modifiers): + if self._dragging: + self._end = (x, y) + + def on_release(self, x, y, button, modifiers): + if button == Qt.MouseButton.LeftButton and self._dragging: + self._end = (x, y) + self._commit() + self.model.end_stroke() + self._dragging = False + self._start = None + self._end = None + + def _commit(self): + pass + + # Helpers for subclasses to convert pixel coords -> widget coords + def _widget_xy(self, canvas, px, py): + ox, oy = canvas._canvas_origin() + cell = max(1, int(canvas._cell_size())) + return int(ox + px * cell), int(oy + py * cell) + + def _overlay_pen(self): + pen = QPen(QColor(255, 255, 0, 180)) + pen.setWidth(2) + pen.setStyle(Qt.PenStyle.DashLine) + return pen + + +def _bresenham(x0, y0, x1, y1): + """Yield (x, y) pixels along a line from (x0, y0) to (x1, y1).""" + dx = abs(x1 - x0) + dy = abs(y1 - y0) + sx = 1 if x0 < x1 else -1 + sy = 1 if y0 < y1 else -1 + err = dx - dy + while True: + yield x0, y0 + if x0 == x1 and y0 == y1: + break + e2 = 2 * err + if e2 > -dy: + err -= dy + x0 += sx + if e2 < dx: + err += dx + y0 += sy + + +class LineTool(DragShapeTool): + name = "Line" + + def _commit(self): + img = self.model.get_image() + w, h = img.width, img.height + for sx, sy, ex, ey in self.model._mirror_pair(*self._start, *self._end): + for px, py in _bresenham(sx, sy, ex, ey): + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + self.model.face_updated.emit(self.model.active_face) + + def draw_overlay(self, painter, canvas): + if not self._dragging or self._start is None: + return + cell = max(1, int(canvas._cell_size())) + half = cell // 2 + sx, sy = self._widget_xy(canvas, self._start[0], self._start[1]) + ex, ey = self._widget_xy(canvas, self._end[0], self._end[1]) + painter.setPen(self._overlay_pen()) + painter.drawLine(sx + half, sy + half, ex + half, ey + half) + + +class RectTool(DragShapeTool): + name = "Rectangle" + + def _commit(self): + img = self.model.get_image() + w, h = img.width, img.height + for sx, sy, ex, ey in self.model._mirror_pair(*self._start, *self._end): + lx, rx = min(sx, ex), max(sx, ex) + ty, by = min(sy, ey), max(sy, ey) + if self.filled: + for py in range(ty, by + 1): + for px in range(lx, rx + 1): + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + else: + for px in range(lx, rx + 1): + for py in (ty, by): + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + for py in range(ty, by + 1): + for px in (lx, rx): + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + self.model.face_updated.emit(self.model.active_face) + + def draw_overlay(self, painter, canvas): + if not self._dragging or self._start is None: + return + cell = max(1, int(canvas._cell_size())) + sx, sy = self._widget_xy(canvas, min(self._start[0], self._end[0]), + min(self._start[1], self._end[1])) + ex, ey = self._widget_xy(canvas, max(self._start[0], self._end[0]), + max(self._start[1], self._end[1])) + painter.setPen(self._overlay_pen()) + painter.drawRect(sx, sy, ex - sx + cell, ey - sy + cell) + + +def _ellipse_pixels(cx, cy, rx, ry): + """Yield outline pixels for an axis-aligned ellipse (midpoint algorithm).""" + if rx == 0 and ry == 0: + yield cx, cy + return + if rx == 0: + for y in range(-ry, ry + 1): + yield cx, cy + y + return + if ry == 0: + for x in range(-rx, rx + 1): + yield cx + x, cy + return + + points = set() + x = 0 + y = ry + rx2 = rx * rx + ry2 = ry * ry + p1 = ry2 - rx2 * ry + rx2 // 4 + + while 2 * ry2 * x <= 2 * rx2 * y: + points.add((cx + x, cy + y)) + points.add((cx - x, cy + y)) + points.add((cx + x, cy - y)) + points.add((cx - x, cy - y)) + x += 1 + if p1 < 0: + p1 += 2 * ry2 * x + ry2 + else: + y -= 1 + p1 += 2 * ry2 * x - 2 * rx2 * y + ry2 + + p2 = (ry2 * (x * 2 + 1) * (x * 2 + 1)) // 4 + rx2 * (y - 1) * (y - 1) - rx2 * ry2 + while y >= 0: + points.add((cx + x, cy + y)) + points.add((cx - x, cy + y)) + points.add((cx + x, cy - y)) + points.add((cx - x, cy - y)) + y -= 1 + if p2 > 0: + p2 -= 2 * rx2 * y + rx2 + else: + x += 1 + p2 += 2 * ry2 * x - 2 * rx2 * y + rx2 + + yield from points + + +class EllipseTool(DragShapeTool): + name = "Ellipse" + + def _commit(self): + img = self.model.get_image() + w, h = img.width, img.height + + for sx, sy, ex, ey in self.model._mirror_pair(*self._start, *self._end): + lx, rx = min(sx, ex), max(sx, ex) + ty, by = min(sy, ey), max(sy, ey) + cx_f = (lx + rx) / 2.0 + cy_f = (ty + by) / 2.0 + erx = (rx - lx) / 2.0 + ery = (by - ty) / 2.0 + + if self.filled: + for py in range(ty, by + 1): + for px in range(lx, rx + 1): + if erx > 0 and ery > 0: + dx = (px - cx_f) / erx + dy = (py - cy_f) / ery + if dx * dx + dy * dy > 1.0: + continue + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + else: + icx = round(cx_f) + icy = round(cy_f) + irx = max(0, round(erx)) + iry = max(0, round(ery)) + for px, py in _ellipse_pixels(icx, icy, irx, iry): + if 0 <= px < w and 0 <= py < h: + self.model.set_pixel(px, py, emit=False) + + self.model.face_updated.emit(self.model.active_face) + + def draw_overlay(self, painter, canvas): + if not self._dragging or self._start is None: + return + cell = max(1, int(canvas._cell_size())) + sx, sy = self._widget_xy(canvas, min(self._start[0], self._end[0]), + min(self._start[1], self._end[1])) + ex, ey = self._widget_xy(canvas, max(self._start[0], self._end[0]), + max(self._start[1], self._end[1])) + painter.setPen(self._overlay_pen()) + painter.drawEllipse(sx, sy, ex - sx + cell, ey - sy + cell) + + +# --------------------------------------------------------------------------- +# Gradient tool +# --------------------------------------------------------------------------- + +class GradientTool(DragShapeTool): + name = "Gradient" + + def __init__(self, model): + super().__init__(model) + self.end_color = (255, 255, 255, 255) + + def _commit(self): + x0, y0 = self._start + x1, y1 = self._end + dx = x1 - x0 + dy = y1 - y0 + length_sq = dx * dx + dy * dy + img = self.model.get_image() + w, h = img.width, img.height + pixels = img.load() + c0 = self.model.current_color + c1 = self.end_color + + # Constrain to selection if active + sel = self.model.selection_rect + if sel: + min_x = max(0, sel[0]) + min_y = max(0, sel[1]) + max_x = min(w, sel[0] + sel[2]) + max_y = min(h, sel[1] + sel[3]) + else: + min_x, min_y = 0, 0 + max_x, max_y = w, h + + for py in range(min_y, max_y): + for px in range(min_x, max_x): + if length_sq == 0: + t = 0.0 + else: + t = ((px - x0) * dx + (py - y0) * dy) / length_sq + t = max(0.0, min(1.0, t)) + r = int(c0[0] + (c1[0] - c0[0]) * t) + g = int(c0[1] + (c1[1] - c0[1]) * t) + b = int(c0[2] + (c1[2] - c0[2]) * t) + a = int(c0[3] + (c1[3] - c0[3]) * t) + pixels[px, py] = (r, g, b, a) + self.model._expand_stroke_bbox(px, py) + + self.model.face_updated.emit(self.model.active_face) + + def draw_overlay(self, painter, canvas): + if not self._dragging or self._start is None: + return + cell = max(1, int(canvas._cell_size())) + half = cell // 2 + sx, sy = self._widget_xy(canvas, self._start[0], self._start[1]) + ex, ey = self._widget_xy(canvas, self._end[0], self._end[1]) + painter.setPen(self._overlay_pen()) + painter.drawLine(sx + half, sy + half, ex + half, ey + half) + # Color dots at endpoints + c0 = self.model.current_color + c1 = self.end_color + painter.setBrush(QColor(*c0)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(sx + half - 5, sy + half - 5, 10, 10) + painter.setBrush(QColor(*c1)) + painter.drawEllipse(ex + half - 5, ey + half - 5, 10, 10) + + +# --------------------------------------------------------------------------- +# Selection tool +# --------------------------------------------------------------------------- + +class SelectionTool(Tool): + name = "Selection" + + STATE_IDLE = "idle" + STATE_SELECTED = "selected" + STATE_FLOATING = "floating" + + def __init__(self, model): + super().__init__(model) + self._state = self.STATE_IDLE + self._rect = None # (x, y, w, h) in pixel coords + self._clipboard = None # PIL Image + self._float_image = None # floating paste image + self._float_pos = None # (x, y) + self._drag_start = None # mouse start for drag + self._drag_origin = None # original rect/float pos at drag start + self._dragging_selection = False + self._dragging_float = False + self._creating = False + + def _update_model_selection(self): + """Sync selection_rect on the model for other tools (e.g. gradient).""" + if self._state == self.STATE_SELECTED and self._rect: + self.model.selection_rect = self._rect + else: + self.model.selection_rect = None + + def _point_in_rect(self, px, py): + if self._rect is None: + return False + rx, ry, rw, rh = self._rect + return rx <= px < rx + rw and ry <= py < ry + rh + + def on_press(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return False + + # Floating state: click commits, then start fresh + if self._state == self.STATE_FLOATING: + self._commit_float() + + # If selected and clicking inside, start moving selection content + if self._state == self.STATE_SELECTED and self._point_in_rect(x, y): + self._start_move(x, y) + return True + + # Start new selection + self._state = self.STATE_IDLE + self._rect = None + self._update_model_selection() + self._drag_start = (x, y) + self._creating = True + return True + + def on_move(self, x, y, buttons, modifiers): + if self._creating and self._drag_start is not None: + sx, sy = self._drag_start + lx, ty = min(sx, x), min(sy, y) + rw = abs(x - sx) + 1 + rh = abs(y - sy) + 1 + self._rect = (lx, ty, rw, rh) + elif self._dragging_float and self._float_pos is not None: + ox, oy = self._drag_origin + sx, sy = self._drag_start + self._float_pos = (ox + x - sx, oy + y - sy) + elif self._dragging_selection and self._rect is not None: + ox, oy, ow, oh = self._drag_origin + sx, sy = self._drag_start + self._rect = (ox + x - sx, oy + y - sy, ow, oh) + + def on_release(self, x, y, button, modifiers): + if button != Qt.MouseButton.LeftButton: + return + if self._creating: + self._creating = False + if self._rect and self._rect[2] > 0 and self._rect[3] > 0: + self._state = self.STATE_SELECTED + else: + self._state = self.STATE_IDLE + self._rect = None + self._update_model_selection() + self._dragging_selection = False + self._dragging_float = False + + def _start_move(self, x, y): + """Cut selection content into a floating image and start dragging it.""" + from PIL import Image + rx, ry, rw, rh = self._rect + img = self.model.get_image() + self.model.begin_stroke() + self._float_image = img.crop((rx, ry, rx + rw, ry + rh)).copy() + # Clear the original area + transparent = (0, 0, 0, 0) + pixels = img.load() + for py in range(ry, ry + rh): + for px in range(rx, rx + rw): + if 0 <= px < img.width and 0 <= py < img.height: + pixels[px, py] = transparent + self.model.face_updated.emit(self.model.active_face) + self.model.end_stroke() + + self._float_pos = (rx, ry) + self._drag_start = (x, y) + self._drag_origin = (rx, ry) + self._dragging_float = True + self._state = self.STATE_FLOATING + self._update_model_selection() + + def _commit_float(self): + """Paste floating image onto the canvas.""" + if self._float_image is None or self._float_pos is None: + self._state = self.STATE_IDLE + self._update_model_selection() + return + self.model.begin_stroke() + img = self.model.get_image() + fx, fy = self._float_pos + img.paste(self._float_image, (int(fx), int(fy)), self._float_image) + self.model.face_updated.emit(self.model.active_face) + self.model.end_stroke() + self._float_image = None + self._float_pos = None + self._rect = None + self._state = self.STATE_IDLE + self._update_model_selection() + + def on_key_press(self, key, modifiers): + ctrl = modifiers & Qt.KeyboardModifier.ControlModifier + + if key == Qt.Key.Key_Escape: + if self._state == self.STATE_FLOATING: + # Cancel float — discard without committing + self._float_image = None + self._float_pos = None + self._rect = None + self._state = self.STATE_IDLE + self._update_model_selection() + return True + + if key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter: + if self._state == self.STATE_FLOATING: + self._commit_float() + return True + + if key == Qt.Key.Key_Delete or key == Qt.Key.Key_Backspace: + if self._state == self.STATE_SELECTED and self._rect: + self._delete_selection() + return True + + if ctrl and key == Qt.Key.Key_C: + if self._rect and self._state == self.STATE_SELECTED: + self._copy_selection() + return True + + if ctrl and key == Qt.Key.Key_X: + if self._rect and self._state == self.STATE_SELECTED: + self._cut_selection() + return True + + if ctrl and key == Qt.Key.Key_V: + if self._clipboard is not None: + self._paste() + return True + + return False + + def _copy_selection(self): + rx, ry, rw, rh = self._rect + img = self.model.get_image() + self._clipboard = img.crop((rx, ry, rx + rw, ry + rh)).copy() + + def _cut_selection(self): + self._copy_selection() + self._delete_selection() + + def _delete_selection(self): + from PIL import Image + rx, ry, rw, rh = self._rect + img = self.model.get_image() + self.model.begin_stroke() + pixels = img.load() + transparent = (0, 0, 0, 0) + for py in range(ry, ry + rh): + for px in range(rx, rx + rw): + if 0 <= px < img.width and 0 <= py < img.height: + pixels[px, py] = transparent + self.model.face_updated.emit(self.model.active_face) + self.model.end_stroke() + self._rect = None + self._state = self.STATE_IDLE + self._update_model_selection() + + def _paste(self): + if self._state == self.STATE_FLOATING: + self._commit_float() + self._float_image = self._clipboard.copy() + self._float_pos = (0, 0) + self._state = self.STATE_FLOATING + self._update_model_selection() + + def draw_overlay(self, painter, canvas): + cell = max(1, int(canvas._cell_size())) + ox, oy = canvas._canvas_origin() + + # Draw floating image + if self._state == self.STATE_FLOATING and self._float_image is not None: + fx, fy = self._float_pos + fw, fh = self._float_image.width, self._float_image.height + # Convert PIL image to QImage for overlay + raw = self._float_image.tobytes("raw", "BGRA") + from PyQt6.QtGui import QImage + qimg = QImage(raw, fw, fh, QImage.Format.Format_ARGB32) + self._float_bytes = raw # prevent GC + wx = int(ox + fx * cell) + wy = int(oy + fy * cell) + scaled = qimg.scaled(fw * cell, fh * cell) + painter.setOpacity(0.7) + painter.drawImage(wx, wy, scaled) + painter.setOpacity(1.0) + # Dashed border around float + self._draw_dashed_rect(painter, wx, wy, fw * cell, fh * cell) + + # Draw selection rectangle + if self._rect and self._state in (self.STATE_SELECTED, self.STATE_IDLE) and self._creating: + rx, ry, rw, rh = self._rect + wx = int(ox + rx * cell) + wy = int(oy + ry * cell) + self._draw_dashed_rect(painter, wx, wy, rw * cell, rh * cell) + elif self._rect and self._state == self.STATE_SELECTED: + rx, ry, rw, rh = self._rect + wx = int(ox + rx * cell) + wy = int(oy + ry * cell) + self._draw_dashed_rect(painter, wx, wy, rw * cell, rh * cell) + + def _draw_dashed_rect(self, painter, x, y, w, h): + """Draw a marching-ants style dashed rectangle.""" + # White pass + pen = QPen(QColor(255, 255, 255, 200)) + pen.setWidth(2) + pen.setStyle(Qt.PenStyle.DashLine) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(x, y, w, h) + # Black pass offset + pen2 = QPen(QColor(0, 0, 0, 200)) + pen2.setWidth(2) + pen2.setStyle(Qt.PenStyle.DashLine) + pen2.setDashOffset(4) + painter.setPen(pen2) + painter.drawRect(x, y, w, h) + + +# Tool classes for the tool dropdown (persistent tools you switch between) +ALL_TOOLS = [ + BrushTool, LineTool, RectTool, EllipseTool, + GradientTool, SelectionTool, +] + +# One-shot action tools (used once then revert to previous tool) +ACTION_TOOLS = { + "Fill": FillTool, + "Eyedropper": EyedropperTool, +} diff --git a/tools/texture-editor/main.py b/tools/texture-editor/main.py index 5108248..7cf920e 100644 --- a/tools/texture-editor/main.py +++ b/tools/texture-editor/main.py @@ -14,17 +14,20 @@ from editor.color_picker import ColorPicker from editor.preview_3d import Preview3D from editor.face_panel import FacePanel +from editor.tile_preview import TilePreview +from editor.layer_panel import LayerPanel +from editor.tools import ALL_TOOLS, ACTION_TOOLS, DragShapeTool, GradientTool class TextureEditor(QMainWindow): def __init__(self, size=32, block_id=None): super().__init__() self.setWindowTitle("Atlas Texture Editor") - self.resize(1100, 650) + self.resize(1400, 850) self.model = TextureModel(size) - # Central splitter: left (canvas + color picker) | right (3D preview) + # Central splitter: left (canvas + color picker) | middle (3D + tile) | right splitter = QSplitter(Qt.Orientation.Horizontal) # Left side: canvas + color picker @@ -40,18 +43,57 @@ def __init__(self, size=32, block_id=None): splitter.addWidget(left) - # Middle: 3D preview + # Middle: 3D preview + tiling preview + middle = QWidget() + middle_layout = QVBoxLayout(middle) + middle_layout.setContentsMargins(0, 0, 0, 0) + self.preview = Preview3D(self.model) - splitter.addWidget(self.preview) + middle_layout.addWidget(self.preview, stretch=2) + + self.tile_preview = TilePreview(self.model) + middle_layout.addWidget(self.tile_preview, stretch=1) + + splitter.addWidget(middle) + + # Right side: face panel + layer panel + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(0) - # Right side: face panel self.face_panel = FacePanel(self.model) - self.face_panel.setFixedWidth(280) - splitter.addWidget(self.face_panel) + right_layout.addWidget(self.face_panel) + + self.layer_panel = LayerPanel(self.model) + right_layout.addWidget(self.layer_panel) - splitter.setSizes([380, 380, 280]) + right.setFixedWidth(280) + splitter.addWidget(right) + + splitter.setSizes([500, 420, 280]) self.setCentralWidget(splitter) + # Tool system + self._tools = {} + for tool_cls in ALL_TOOLS: + tool = tool_cls(self.model) + self._tools[tool.name] = tool + self.canvas.set_tool(self._tools["Brush"]) + + # One-shot action tools (eyedropper, fill) + self._action_tools = {} + for name, cls in ACTION_TOOLS.items(): + self._action_tools[name] = cls(self.model) + + self.color_picker.tool_changed.connect(self._on_tool_changed) + self.color_picker.filled_changed.connect(self._on_filled_changed) + self.color_picker.end_color_changed.connect(self._on_end_color_changed) + self.color_picker.eyedropper_clicked.connect( + lambda: self._activate_action("Eyedropper")) + self.color_picker.fill_clicked.connect( + lambda: self._activate_action("Fill")) + self._setup_shortcuts() # Load block if specified via CLI @@ -73,6 +115,39 @@ def _load_initial_block(self, block_id): self.model.load_block_by_filename(block_id, DEFAULT_TEXTURE_DIR) self.face_panel._refresh_state_combo() + def _on_tool_changed(self, tool_name): + tool = self._tools.get(tool_name) + if tool: + self.canvas.set_tool(tool) + # Show/hide filled checkbox + self.color_picker.set_filled_visible(isinstance(tool, DragShapeTool) + and not isinstance(tool, GradientTool)) + # Show/hide gradient end color + self.color_picker.set_end_color_visible(isinstance(tool, GradientTool)) + + def _on_filled_changed(self, filled): + tool = self.canvas._active_tool + if isinstance(tool, DragShapeTool): + tool.filled = filled + + def _on_end_color_changed(self, color): + tool = self._tools.get("Gradient") + if isinstance(tool, GradientTool): + tool.end_color = color + + def _activate_action(self, name): + tool = self._action_tools.get(name) + if tool: + self.canvas.activate_one_shot(tool) + + def _select_tool(self, name): + if name in self._action_tools: + self._activate_action(name) + return + idx = self.color_picker.tool_combo.findText(name) + if idx >= 0: + self.color_picker.tool_combo.setCurrentIndex(idx) + def _setup_shortcuts(self): # Ctrl+S: save current face save_sc = QShortcut(QKeySequence("Ctrl+S"), self) @@ -97,6 +172,13 @@ def _setup_shortcuts(self): inc_brush = QShortcut(QKeySequence("]"), self) inc_brush.activated.connect(self._increase_brush) + # Tool shortcuts + for key, name in [("B", "Brush"), ("F", "Fill"), ("I", "Eyedropper"), + ("L", "Line"), ("R", "Rectangle"), + ("E", "Ellipse"), ("G", "Gradient"), ("M", "Selection")]: + sc = QShortcut(QKeySequence(key), self) + sc.activated.connect(lambda n=name: self._select_tool(n)) + def _decrease_brush(self): new_val = max(1, self.model.brush_size - 1) self.model.brush_size = new_val