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..7c1ef54 --- /dev/null +++ b/tools/texture-editor/editor/color_picker.py @@ -0,0 +1,210 @@ +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QColorDialog, + QLabel, QSpinBox, QComboBox, QCheckBox, +) +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.""" + + 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) + 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 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 + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Row 1: color swatch + pick button + brush size + symmetry + 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) + + 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() + 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.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) + + # 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) + 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) + 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) + + 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 _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) + 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 + + 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 new file mode 100644 index 0000000..1f0c230 --- /dev/null +++ b/tools/texture-editor/editor/face_panel.py @@ -0,0 +1,368 @@ +import os +import yaml +from PIL import Image +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QComboBox, QFileDialog, QLabel, QMessageBox, QCompleter, QSlider, +) +from PyQt6.QtCore import Qt + +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) + + # 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) + + 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) + + 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) + + 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() + + if os.path.isdir(CONFIG_DIR): + for fname in os.listdir(CONFIG_DIR): + if fname.endswith(".yml"): + block_ids.add(fname[:-4]) + + 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 + is_sub_texture = any( + candidate.startswith(bid + "_") for bid in block_ids + ) + if is_sub_texture: + continue + 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 + ) + 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 + + 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() + self._update_save_block_btn() + return + + 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): + 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 _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", + 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_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", + self._texture_dir, + ) + if not directory: + return + 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) + 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.""" + if 1 <= num <= 6: + face = FACE_NAMES[num - 1] + self._on_face_clicked(face) 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 new file mode 100644 index 0000000..67f69a4 --- /dev/null +++ b/tools/texture-editor/editor/pixel_canvas.py @@ -0,0 +1,256 @@ +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 tool delegation.""" + + 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._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.""" + if self._zoom > 0: + return self._zoom + # Fit to widget + fit = min(self.width(), self.height()) / max(self.model.size, 1) + return max(1.0, fit) + + def _rebuild_cache(self): + 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 + 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 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") + 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 + + # 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.""" + cell = max(1, int(self._cell_size())) + 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 + + 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) + + # 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() + + 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 mousePressEvent(self, event: QMouseEvent): + # Middle button: pan (universal) + 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() + + # 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: + 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 + + x, y = self._pixel_at(event.position().toPoint()) + self._active_tool.on_move(x, y, event.buttons(), event.modifiers()) + + # Repaint for cursor / overlay updates + 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 + + 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() + 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 + 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 + 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..3e33bb0 --- /dev/null +++ b/tools/texture-editor/editor/preview_3d.py @@ -0,0 +1,190 @@ +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, 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, + 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_composite(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_composite(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_composite(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_composite(tex_key) + if img.mode != "RGBA": + return False + extrema = img.getextrema() + 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 + glDepthMask(GL_TRUE) + for face_data in opaque: + self._draw_face(face_data) + + # Pass 2: transparent faces + 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..b6de5e1 --- /dev/null +++ b/tools/texture-editor/editor/texture_model.py @@ -0,0 +1,875 @@ +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": { + "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"] + + 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(): + tex_ref = face_data.get("texture", "") + tex_key = tex_ref.lstrip("#") if tex_ref.startswith("#") else face_dir + + if "uv" in face_data: + mu1, mv1, mu2, mv2 = face_data["uv"] + else: + mu1, mv1, mu2, mv2 = 0, 0, 16, 16 + + 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), + ] + + 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 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 layered face textures and optional geometry.""" + + def __init__(self, name, size=32): + self.name = name + 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.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 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__() + 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.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): + 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): + """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 + 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 layer textures across all states to a new size.""" + self.size = new_size + for state in self.states: + for face in FACE_NAMES: + 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, 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 + 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 + 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) with symmetry.""" + img = self.get_image() + w, h = img.width, img.height + offset = brush_size // 2 + changed = False + 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 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") + 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.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.""" + 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 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.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 + + 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])) + + 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", {}) + + 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 + + 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._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.""" + self.states.clear() + self.active_state_index = 0 + + block_state = BlockState(block_id, self.size) + loaded_any = False + + 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.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.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: + 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(): + 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: + 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.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) + if not loaded_any: + self.states[0] = BlockState("default", self.size) + + 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 new file mode 100644 index 0000000..7cf920e --- /dev/null +++ b/tools/texture-editor/main.py @@ -0,0 +1,209 @@ +#!/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 +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(1400, 850) + + self.model = TextureModel(size) + + # Central splitter: left (canvas + color picker) | middle (3D + tile) | right + 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 + tiling preview + middle = QWidget() + middle_layout = QVBoxLayout(middle) + middle_layout.setContentsMargins(0, 0, 0, 0) + + self.preview = Preview3D(self.model) + 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) + + self.face_panel = FacePanel(self.model) + right_layout.addWidget(self.face_panel) + + self.layer_panel = LayerPanel(self.model) + right_layout.addWidget(self.layer_panel) + + 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 + 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 _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) + 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) + + # 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 + 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