diff --git a/tools/texture-editor/editor/model_panel.py b/tools/texture-editor/editor/model_panel.py new file mode 100644 index 0000000..9c59c9d --- /dev/null +++ b/tools/texture-editor/editor/model_panel.py @@ -0,0 +1,468 @@ +"""Model editor panel — create and modify block shape elements.""" + +import json +import os + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QListWidget, QListWidgetItem, QSpinBox, + QCheckBox, QComboBox, QFileDialog, QMessageBox, + QGroupBox, QGridLayout, +) +from PyQt6.QtCore import Qt + +from editor.texture_model import FACE_NAMES, MODELS_DIR, ModelData, ModelElement + + +CULLFACE_OPTIONS = ["(none)"] + FACE_NAMES + +CUSTOM_MODELS_DIR = os.path.join(MODELS_DIR, "block", "custom") + + +class ModelPanel(QWidget): + """Displays and edits block model elements for the active state.""" + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self._selected_elem = -1 + self._selected_face = None + self._updating = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Header + header_row = QHBoxLayout() + header_row.addWidget(QLabel("Model")) + self._file_label = QLabel("") + self._file_label.setStyleSheet("color: #888; font-size: 11px;") + header_row.addWidget(self._file_label, stretch=1) + layout.addLayout(header_row) + + # Element list + self.elem_list = QListWidget() + self.elem_list.setMaximumHeight(120) + self.elem_list.currentRowChanged.connect(self._on_elem_selected) + layout.addWidget(self.elem_list) + + # Element buttons + elem_btn_row = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.setToolTip("Add element") + add_btn.clicked.connect(self._add_element) + elem_btn_row.addWidget(add_btn) + + del_btn = QPushButton("-") + del_btn.setFixedWidth(30) + del_btn.setToolTip("Delete element") + del_btn.clicked.connect(self._delete_element) + elem_btn_row.addWidget(del_btn) + + dup_btn = QPushButton("Dup") + dup_btn.setToolTip("Duplicate element") + dup_btn.clicked.connect(self._duplicate_element) + elem_btn_row.addWidget(dup_btn) + + self._wireframe_cb = QCheckBox("Wireframe") + self._wireframe_cb.setChecked(False) + self._wireframe_cb.toggled.connect(self._on_wireframe_toggled) + elem_btn_row.addStretch() + elem_btn_row.addWidget(self._wireframe_cb) + layout.addLayout(elem_btn_row) + + # Dimension editors + self._dims_group = QGroupBox("Dimensions") + dims_layout = QGridLayout(self._dims_group) + dims_layout.setContentsMargins(4, 4, 4, 4) + + dims_layout.addWidget(QLabel("From:"), 0, 0) + self._from_spins = [] + for i, axis in enumerate(("X", "Y", "Z")): + spin = QSpinBox() + spin.setRange(0, 16) + spin.setPrefix(f"{axis}: ") + spin.valueChanged.connect(self._on_dims_changed) + self._from_spins.append(spin) + dims_layout.addWidget(spin, 0, i + 1) + + dims_layout.addWidget(QLabel("To:"), 1, 0) + self._to_spins = [] + for i, axis in enumerate(("X", "Y", "Z")): + spin = QSpinBox() + spin.setRange(0, 16) + spin.setPrefix(f"{axis}: ") + spin.valueChanged.connect(self._on_dims_changed) + self._to_spins.append(spin) + dims_layout.addWidget(spin, 1, i + 1) + + self._dims_group.setVisible(False) + layout.addWidget(self._dims_group) + + # Face visibility checkboxes + self._faces_group = QGroupBox("Faces") + faces_layout = QGridLayout(self._faces_group) + faces_layout.setContentsMargins(4, 4, 4, 4) + + self._face_checks = {} + face_labels = {"north": "N", "south": "S", "east": "E", + "west": "W", "up": "Up", "down": "Dn"} + for i, face in enumerate(FACE_NAMES): + cb = QCheckBox(face_labels[face]) + cb.clicked.connect(lambda checked, f=face: self._on_face_toggled(f, checked)) + self._face_checks[face] = cb + faces_layout.addWidget(cb, i // 3, i % 3) + + self._faces_group.setVisible(False) + layout.addWidget(self._faces_group) + + # Per-face UV editor + self._uv_group = QGroupBox("Face UV") + uv_layout = QGridLayout(self._uv_group) + uv_layout.setContentsMargins(4, 4, 4, 4) + + self._face_select_combo = QComboBox() + for face in FACE_NAMES: + self._face_select_combo.addItem(face.capitalize(), face) + self._face_select_combo.currentIndexChanged.connect(self._on_uv_face_changed) + uv_layout.addWidget(QLabel("Face:"), 0, 0) + uv_layout.addWidget(self._face_select_combo, 0, 1, 1, 3) + + uv_labels = ["U1", "V1", "U2", "V2"] + self._uv_spins = [] + for i, label in enumerate(uv_labels): + spin = QSpinBox() + spin.setRange(0, 16) + spin.setPrefix(f"{label}: ") + spin.valueChanged.connect(self._on_uv_changed) + self._uv_spins.append(spin) + uv_layout.addWidget(spin, 1, i) + + uv_layout.addWidget(QLabel("Cullface:"), 2, 0) + self._cullface_combo = QComboBox() + for opt in CULLFACE_OPTIONS: + self._cullface_combo.addItem(opt) + self._cullface_combo.currentIndexChanged.connect(self._on_cullface_changed) + uv_layout.addWidget(self._cullface_combo, 2, 1, 1, 3) + + self._uv_group.setVisible(False) + layout.addWidget(self._uv_group) + + # File operations + layout.addSpacing(8) + save_btn = QPushButton("Save Model") + save_btn.clicked.connect(self._save_model) + layout.addWidget(save_btn) + + save_as_btn = QPushButton("Save As...") + save_as_btn.clicked.connect(self._save_model_as) + layout.addWidget(save_as_btn) + + new_btn = QPushButton("New Model") + new_btn.setToolTip( + "Create an editable model for blocks using a default cube parent.\n" + "Note: You will need to update the YAML parent reference manually." + ) + new_btn.clicked.connect(self._new_model) + layout.addWidget(new_btn) + + self._yaml_note = QLabel( + "Note: Creating a new model requires\n" + "updating the YAML parent reference." + ) + self._yaml_note.setStyleSheet("color: #a87; font-size: 10px;") + self._yaml_note.setVisible(False) + layout.addWidget(self._yaml_note) + + # Connect signals + self.model.state_changed.connect(lambda _: self._refresh()) + self.model.geometry_changed.connect(self._refresh) + + self._refresh() + + def _get_model_data(self): + return self.model.active_state.model_data + + def _refresh(self): + self._updating = True + md = self._get_model_data() + + # File label + if md and md.source_path: + self._file_label.setText(os.path.basename(md.source_path)) + elif md: + self._file_label.setText("(unsaved)") + else: + self._file_label.setText("Default cube") + + # Element list + self.elem_list.clear() + if md: + for i, elem in enumerate(md.elements): + f = elem.from_pos + t = elem.to_pos + text = f"Element {i} ({f[0]},{f[1]},{f[2]}" \ + + f" \u2192 {t[0]},{t[1]},{t[2]})" + self.elem_list.addItem(text) + + if 0 <= self._selected_elem < len(md.elements): + self.elem_list.setCurrentRow(self._selected_elem) + elif md.elements: + self._selected_elem = 0 + self.elem_list.setCurrentRow(0) + else: + self._selected_elem = -1 + else: + self._selected_elem = -1 + + self._sync_selected_element() + has_selection = md is not None and 0 <= self._selected_elem < len(md.elements) + self._dims_group.setVisible(has_selection) + self._faces_group.setVisible(has_selection) + self._uv_group.setVisible(has_selection) + + if has_selection: + elem = md.elements[self._selected_elem] + for i in range(3): + self._from_spins[i].setValue(int(elem.from_pos[i])) + self._to_spins[i].setValue(int(elem.to_pos[i])) + for face in FACE_NAMES: + self._face_checks[face].setChecked(face in elem.faces) + self._refresh_uv() + + self._updating = False + + def _refresh_uv(self): + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + elem = md.elements[self._selected_elem] + face = self._face_select_combo.currentData() + if face and face in elem.faces: + face_data = elem.faces[face] + uv = face_data.get("uv", [0, 0, 16, 16]) + for i in range(4): + self._uv_spins[i].setValue(int(uv[i])) + cullface = face_data.get("cullface", "") + idx = CULLFACE_OPTIONS.index(cullface) if cullface in CULLFACE_OPTIONS else 0 + self._cullface_combo.setCurrentIndex(idx) + for spin in self._uv_spins: + spin.setEnabled(True) + self._cullface_combo.setEnabled(True) + else: + for spin in self._uv_spins: + spin.setValue(0) + spin.setEnabled(False) + self._cullface_combo.setCurrentIndex(0) + self._cullface_combo.setEnabled(False) + + def _on_elem_selected(self, row): + if self._updating: + return + self._selected_elem = row + self._updating = True + md = self._get_model_data() + has_selection = md is not None and 0 <= row < len(md.elements) + self._dims_group.setVisible(has_selection) + self._faces_group.setVisible(has_selection) + self._uv_group.setVisible(has_selection) + if has_selection: + elem = md.elements[row] + for i in range(3): + self._from_spins[i].setValue(int(elem.from_pos[i])) + self._to_spins[i].setValue(int(elem.to_pos[i])) + for face in FACE_NAMES: + self._face_checks[face].setChecked(face in elem.faces) + self._refresh_uv() + self._updating = False + self._sync_selected_element() + self.model.geometry_changed.emit() + + def _on_dims_changed(self): + if self._updating: + return + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + from_pos = [self._from_spins[i].value() for i in range(3)] + to_pos = [self._to_spins[i].value() for i in range(3)] + md.update_element(self._selected_elem, from_pos=from_pos, to_pos=to_pos) + self._auto_update_uvs(md.elements[self._selected_elem]) + self._emit_geometry_changed() + + @staticmethod + def _auto_update_uvs(elem): + """Recalculate UVs from element dimensions, matching Minecraft's auto-UV.""" + fp = elem.from_pos + tp = elem.to_pos + uv_map = { + "north": [16 - tp[0], 16 - tp[1], 16 - fp[0], 16 - fp[1]], + "south": [fp[0], 16 - tp[1], tp[0], 16 - fp[1]], + "east": [16 - tp[2], 16 - tp[1], 16 - fp[2], 16 - fp[1]], + "west": [fp[2], 16 - tp[1], tp[2], 16 - fp[1]], + "up": [fp[0], 16 - tp[2], tp[0], 16 - fp[2]], + "down": [fp[0], fp[2], tp[0], tp[2]], + } + for face, uv in uv_map.items(): + if face in elem.faces: + elem.faces[face]["uv"] = uv + + def _on_face_toggled(self, face, checked): + if self._updating: + return + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + md.set_face_visible(self._selected_elem, face, checked) + self._refresh_uv() + self._emit_geometry_changed() + + def _on_uv_face_changed(self): + if self._updating: + return + self._updating = True + self._refresh_uv() + self._updating = False + + def _on_uv_changed(self): + if self._updating: + return + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + face = self._face_select_combo.currentData() + if not face: + return + uv = [self._uv_spins[i].value() for i in range(4)] + md.set_face_uv(self._selected_elem, face, uv) + self._emit_geometry_changed() + + def _on_cullface_changed(self): + if self._updating: + return + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + face = self._face_select_combo.currentData() + if not face: + return + idx = self._cullface_combo.currentIndex() + cullface = CULLFACE_OPTIONS[idx] if idx > 0 else None + md.set_face_cullface(self._selected_elem, face, cullface) + + def _add_element(self): + md = self._get_model_data() + if not md: + return + idx = md.add_element() + self._selected_elem = idx + self._emit_geometry_changed() + + def _delete_element(self): + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + md.remove_element(self._selected_elem) + if self._selected_elem >= len(md.elements): + self._selected_elem = len(md.elements) - 1 + self._emit_geometry_changed() + + def _duplicate_element(self): + md = self._get_model_data() + if not md or not (0 <= self._selected_elem < len(md.elements)): + return + new_idx = md.duplicate_element(self._selected_elem) + if new_idx >= 0: + self._selected_elem = new_idx + self._emit_geometry_changed() + + def _sync_selected_element(self): + self.model.selected_model_element = self._selected_elem + + def _emit_geometry_changed(self): + self._sync_selected_element() + self.model.geometry_changed.emit() + self._refresh() + + def _save_model(self): + md = self._get_model_data() + if not md: + QMessageBox.information(self, "Save Model", "No editable model to save.") + return + if not md.source_path: + self._save_model_as() + return + data = md.to_json() + with open(md.source_path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + QMessageBox.information( + self, "Save Model", + f"Saved to {os.path.basename(md.source_path)}", + ) + + def _save_model_as(self): + md = self._get_model_data() + if not md: + QMessageBox.information(self, "Save Model", "No editable model to save.") + return + start_dir = CUSTOM_MODELS_DIR + if md.source_path: + start_dir = os.path.dirname(md.source_path) + filepath, _ = QFileDialog.getSaveFileName( + self, "Save Model As", + start_dir, + "JSON Files (*.json)", + ) + if not filepath: + return + if not filepath.endswith(".json"): + filepath += ".json" + md.source_path = filepath + data = md.to_json() + with open(filepath, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + self._file_label.setText(os.path.basename(filepath)) + + def _new_model(self): + state = self.model.active_state + if state.model_data is not None: + reply = QMessageBox.question( + self, "New Model", + "Replace the current model with a new default cube?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + md = ModelData( + elements=[ModelElement()], + textures={"particle": "#up"}, + ) + state.model_data = md + self._selected_elem = 0 + self._yaml_note.setVisible(True) + self._emit_geometry_changed() + + def model_undo(self): + md = self._get_model_data() + if md and md.undo(): + self._emit_geometry_changed() + return True + return False + + def model_redo(self): + md = self._get_model_data() + if md and md.redo(): + self._emit_geometry_changed() + return True + return False + + def get_selected_element_index(self): + if self._wireframe_cb.isChecked(): + return self._selected_elem + return -1 + + def _on_wireframe_toggled(self, checked): + self.model.geometry_changed.emit() diff --git a/tools/texture-editor/editor/pixel_canvas.py b/tools/texture-editor/editor/pixel_canvas.py index 67f69a4..c486b6c 100644 --- a/tools/texture-editor/editor/pixel_canvas.py +++ b/tools/texture-editor/editor/pixel_canvas.py @@ -29,6 +29,7 @@ def __init__(self, model, parent=None): self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.model.face_updated.connect(self._on_face_updated) self.model.reference_changed.connect(self._on_reference_changed) + self.model.geometry_changed.connect(self._on_geometry_changed) self._rebuild_cache() def set_tool(self, tool): @@ -50,6 +51,10 @@ def _on_reference_changed(self): self._rebuild_cache() self.update() + def _on_geometry_changed(self): + self._rebuild_cache() + self.update() + def _cell_size(self): """Return the current cell size in widget pixels.""" if self._zoom > 0: @@ -143,6 +148,37 @@ def paintEvent(self, event): painter.drawImage(int(ox), int(oy), self._cached_reference) painter.setOpacity(1.0) + # UV region overlay — dim areas outside, cyan border around mapped region + uv_rect = self.model.get_active_face_uv_pixels() + if uv_rect is not None: + cell = max(1, int(self._cell_size())) + size = self.model.size + canvas_w = cell * size + canvas_h = cell * size + ux1 = int(ox + uv_rect[0] * cell) + uy1 = int(oy + uv_rect[1] * cell) + ux2 = int(ox + uv_rect[2] * cell) + uy2 = int(oy + uv_rect[3] * cell) + ix, iy = int(ox), int(oy) + dim = QColor(0, 0, 0, 120) + # Top + if uy1 > iy: + painter.fillRect(ix, iy, canvas_w, uy1 - iy, dim) + # Bottom + if uy2 < iy + canvas_h: + painter.fillRect(ix, uy2, canvas_w, iy + canvas_h - uy2, dim) + # Left + if ux1 > ix: + painter.fillRect(ix, uy1, ux1 - ix, uy2 - uy1, dim) + # Right + if ux2 < ix + canvas_w: + painter.fillRect(ux2, uy1, ix + canvas_w - ux2, uy2 - uy1, dim) + # Cyan border + pen = QPen(QColor(0, 200, 255, 180)) + pen.setWidth(2) + painter.setPen(pen) + painter.drawRect(ux1, uy1, ux2 - ux1, uy2 - uy1) + # Delegate overlay drawing to the active tool if not self._panning: self._active_tool.draw_overlay(painter, self) diff --git a/tools/texture-editor/editor/preview_3d.py b/tools/texture-editor/editor/preview_3d.py index 3e33bb0..f5226a7 100644 --- a/tools/texture-editor/editor/preview_3d.py +++ b/tools/texture-editor/editor/preview_3d.py @@ -14,10 +14,12 @@ GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE, GL_RGBA, GL_UNSIGNED_BYTE, glBegin, glEnd, glVertex3f, glTexCoord2f, glNormal3f, - GL_QUADS, + GL_QUADS, GL_LINES, glMatrixMode, glLoadIdentity, glTranslatef, glRotatef, GL_PROJECTION, GL_MODELVIEW, glViewport, + glColor3f, glLineWidth, + glPushAttrib, glPopAttrib, GL_ALL_ATTRIB_BITS, ) from OpenGL.GLU import gluPerspective @@ -37,9 +39,11 @@ def __init__(self, model, parent=None): self._zoom = -3.0 self._last_mouse_pos = None self._textures = {} + self._selected_element_index = -1 self.model.face_updated.connect(self._on_face_updated) self.model.state_changed.connect(self._on_state_changed) + self.model.geometry_changed.connect(self._on_geometry_changed) def initializeGL(self): glClearColor(0.15, 0.15, 0.15, 1.0) @@ -100,6 +104,13 @@ def _on_state_changed(self, index): self.doneCurrent() self.update() + def _on_geometry_changed(self): + self.update() + + def set_selected_element(self, index): + self._selected_element_index = index + self.update() + def resizeGL(self, w, h): glViewport(0, 0, w, h) glMatrixMode(GL_PROJECTION) @@ -140,6 +151,7 @@ def paintGL(self): glRotatef(self._azimuth, 0, 1, 0) glEnable(GL_TEXTURE_2D) + glColor3f(1.0, 1.0, 1.0) # Collect all faces, split into opaque and transparent opaque = [] @@ -167,6 +179,47 @@ def paintGL(self): self._draw_face(face_data) glDepthMask(GL_TRUE) + # Pass 3: wireframe highlight for selected element + md = self.model.active_state.model_data + idx = self._selected_element_index + if md is not None and 0 <= idx < len(md.elements): + self._draw_element_wireframe(md.elements[idx]) + + def _draw_element_wireframe(self, elem): + """Draw a wireframe box around the given element for selection highlight.""" + fp = elem.from_pos + tp = elem.to_pos + x1 = fp[0] / 16.0 - 0.5 + y1 = fp[1] / 16.0 - 0.5 + z1 = fp[2] / 16.0 - 0.5 + x2 = tp[0] / 16.0 - 0.5 + y2 = tp[1] / 16.0 - 0.5 + z2 = tp[2] / 16.0 - 0.5 + + corners = [ + (x1, y1, z1), (x2, y1, z1), (x2, y2, z1), (x1, y2, z1), + (x1, y1, z2), (x2, y1, z2), (x2, y2, z2), (x1, y2, z2), + ] + edges = [ + (0, 1), (1, 2), (2, 3), (3, 0), # front face (z1) + (4, 5), (5, 6), (6, 7), (7, 4), # back face (z2) + (0, 4), (1, 5), (2, 6), (3, 7), # connecting edges + ] + + glPushAttrib(GL_ALL_ATTRIB_BITS) + glDisable(GL_DEPTH_TEST) + glDisable(GL_TEXTURE_2D) + glDisable(GL_BLEND) + glDepthMask(GL_FALSE) + glLineWidth(2.0) + glColor3f(1.0, 1.0, 0.0) + glBegin(GL_LINES) + for i, j in edges: + glVertex3f(*corners[i]) + glVertex3f(*corners[j]) + glEnd() + glPopAttrib() + def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: self._last_mouse_pos = event.position() diff --git a/tools/texture-editor/editor/texture_model.py b/tools/texture-editor/editor/texture_model.py index b6de5e1..eecb691 100644 --- a/tools/texture-editor/editor/texture_model.py +++ b/tools/texture-editor/editor/texture_model.py @@ -1,3 +1,4 @@ +import copy import json import os from collections import deque @@ -99,11 +100,239 @@ }] +def _element_to_geometry(from_pos, to_pos, faces_dict): + """Convert a single element's Minecraft coords to GL render geometry. + + from_pos/to_pos: [x, y, z] in 0-16 space. + faces_dict: {face_name: {"texture": "#x", "uv": [...], "cullface": ...}} + Returns a geometry dict with "faces" key. + """ + 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 + + face_defs = { + "north": ((x1, y1, z1), (x2, y1, z1), (x2, y2, z1), (x1, y2, z1), (0, 0, -1)), + "south": ((x2, y1, z2), (x1, y1, z2), (x1, y2, z2), (x2, y2, z2), (0, 0, 1)), + "east": ((x2, y1, z1), (x2, y1, z2), (x2, y2, z2), (x2, y2, z1), (1, 0, 0)), + "west": ((x1, y1, z2), (x1, y1, z1), (x1, y2, z1), (x1, y2, z2), (-1, 0, 0)), + "up": ((x1, y2, z1), (x2, y2, z1), (x2, y2, z2), (x1, y2, z2), (0, 1, 0)), + "down": ((x1, y1, z2), (x2, y1, z2), (x2, y1, z1), (x1, y1, z1), (0, -1, 0)), + } + + faces = {} + for face_dir, face_data in faces_dict.items(): + if face_dir not in face_defs: + continue + v0, v1, v2, v3, normal = face_defs[face_dir] + + 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), + ] + + faces[face_dir] = { + "texture_key": tex_key, + "vertices": [v0, v1, v2, v3], + "tex_coords": tc, + "normal": normal, + } + + return {"faces": faces} + + +class ModelElement: + """One axis-aligned cuboid element in Minecraft 0-16 coordinate space.""" + + def __init__(self, from_pos=None, to_pos=None, faces=None): + self.from_pos = list(from_pos) if from_pos else [0, 0, 0] + self.to_pos = list(to_pos) if to_pos else [16, 16, 16] + if faces is not None: + self.faces = faces + else: + self.faces = {} + for face in FACE_NAMES: + self.faces[face] = { + "texture": f"#{face}", + "uv": [0, 0, 16, 16], + } + + def to_dict(self): + """Serialize to Minecraft model JSON element format.""" + result = { + "from": list(self.from_pos), + "to": list(self.to_pos), + "faces": {}, + } + for face_name, face_data in self.faces.items(): + entry = {"texture": face_data.get("texture", f"#{face_name}")} + uv = face_data.get("uv") + if uv and uv != [0, 0, 16, 16]: + entry["uv"] = list(uv) + cullface = face_data.get("cullface") + if cullface: + entry["cullface"] = cullface + result["faces"][face_name] = entry + return result + + def to_geometry(self): + """Convert this element to GL render geometry.""" + return _element_to_geometry(self.from_pos, self.to_pos, self.faces) + + +class ModelData: + """Editable block model in Minecraft JSON format — source of truth for geometry.""" + + def __init__(self, elements=None, textures=None, source_path=None): + self.elements = elements if elements else [] + self.textures = textures if textures else {} + self.source_path = source_path + self._undo_stack = [] + self._redo_stack = [] + + def to_geometry(self): + """Convert all elements to render-format geometry list.""" + return [elem.to_geometry() for elem in self.elements] + + def to_json(self): + """Serialize to Minecraft model JSON dict.""" + result = {} + if self.textures: + result["textures"] = dict(self.textures) + result["elements"] = [elem.to_dict() for elem in self.elements] + return result + + @staticmethod + def from_json(data, source_path=None): + """Parse a Minecraft model JSON dict into a ModelData instance.""" + textures = data.get("textures", {}) + elements = [] + for elem_data in data.get("elements", []): + faces = {} + for face_name, face_info in elem_data.get("faces", {}).items(): + faces[face_name] = { + "texture": face_info.get("texture", f"#{face_name}"), + "uv": list(face_info.get("uv", [0, 0, 16, 16])), + } + if "cullface" in face_info: + faces[face_name]["cullface"] = face_info["cullface"] + elements.append(ModelElement( + from_pos=elem_data.get("from", [0, 0, 0]), + to_pos=elem_data.get("to", [16, 16, 16]), + faces=faces, + )) + return ModelData(elements=elements, textures=textures, source_path=source_path) + + # ------------------------------------------------------------------ + # Snapshot-based undo / redo + # ------------------------------------------------------------------ + + def _push_undo(self): + snapshot = copy.deepcopy(self.elements) + self._undo_stack.append(snapshot) + if len(self._undo_stack) > 50: + self._undo_stack.pop(0) + self._redo_stack.clear() + + def undo(self): + if not self._undo_stack: + return False + self._redo_stack.append(copy.deepcopy(self.elements)) + self.elements = self._undo_stack.pop() + return True + + def redo(self): + if not self._redo_stack: + return False + self._undo_stack.append(copy.deepcopy(self.elements)) + self.elements = self._redo_stack.pop() + return True + + # ------------------------------------------------------------------ + # Element mutations (all push undo before mutating) + # ------------------------------------------------------------------ + + def add_element(self, from_pos=None, to_pos=None): + self._push_undo() + elem = ModelElement(from_pos=from_pos, to_pos=to_pos) + self.elements.append(elem) + return len(self.elements) - 1 + + def remove_element(self, idx): + if 0 <= idx < len(self.elements): + self._push_undo() + self.elements.pop(idx) + + def duplicate_element(self, idx): + if 0 <= idx < len(self.elements): + self._push_undo() + elem = copy.deepcopy(self.elements[idx]) + self.elements.insert(idx + 1, elem) + return idx + 1 + return -1 + + def update_element(self, idx, from_pos=None, to_pos=None): + if 0 <= idx < len(self.elements): + self._push_undo() + if from_pos is not None: + self.elements[idx].from_pos = list(from_pos) + if to_pos is not None: + self.elements[idx].to_pos = list(to_pos) + + def set_face_visible(self, elem_idx, face, visible): + if not (0 <= elem_idx < len(self.elements)): + return + self._push_undo() + elem = self.elements[elem_idx] + if visible: + if face not in elem.faces: + elem.faces[face] = { + "texture": f"#{face}", + "uv": [0, 0, 16, 16], + } + else: + elem.faces.pop(face, None) + + def set_face_uv(self, elem_idx, face, uv): + if not (0 <= elem_idx < len(self.elements)): + return + elem = self.elements[elem_idx] + if face in elem.faces: + self._push_undo() + elem.faces[face]["uv"] = list(uv) + + def set_face_cullface(self, elem_idx, face, cullface): + if not (0 <= elem_idx < len(self.elements)): + return + elem = self.elements[elem_idx] + if face in elem.faces: + self._push_undo() + if cullface: + elem.faces[face]["cullface"] = cullface + else: + elem.faces[face].pop("cullface", None) + + 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. + Returns (ModelData, geometry_list) tuple, or (None, None) if not found. """ if ":" in model_ref: _, path = model_ref.split(":", 1) @@ -111,95 +340,18 @@ def parse_model_json(model_ref): path = model_ref json_path = os.path.join(MODELS_DIR, path + ".json") if not os.path.exists(json_path): - return None + return None, None with open(json_path, "r") as f: - model_data = json.load(f) + raw_data = json.load(f) - elements = model_data.get("elements", []) + elements = raw_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 None, None - return geometry + md = ModelData.from_json(raw_data, source_path=json_path) + geometry = md.to_geometry() + return md, geometry class Layer: @@ -231,6 +383,7 @@ class BlockState: def __init__(self, name, size=32): self.name = name self.geometry = None + self.model_data = None # ModelData instance when editable model is loaded self.layers = {} # face -> [Layer, ...] self.active_layer = {} # face -> int (index into layers list) self.source_paths = {} # face -> original file path @@ -247,6 +400,7 @@ class TextureModel(QObject): color_changed = pyqtSignal() layers_changed = pyqtSignal() reference_changed = pyqtSignal() + geometry_changed = pyqtSignal() def __init__(self, size=32): super().__init__() @@ -258,6 +412,7 @@ def __init__(self, size=32): self.brush_size = 1 self.symmetry = SYMMETRY_NONE self.selection_rect = None # (x, y, w, h) or None — set by SelectionTool + self.selected_model_element = -1 # index of selected element in model panel self.reference_image = None # PIL Image or None self.reference_opacity = 0.3 self._undo_stack = [] @@ -274,6 +429,9 @@ def active_state(self): def get_geometry(self): """Return the active state's geometry, or the default cube.""" + md = self.active_state.model_data + if md is not None: + return md.to_geometry() geo = self.active_state.geometry return geo if geo is not None else DEFAULT_GEOMETRY @@ -287,6 +445,56 @@ def get_image(self, face=None, state_index=None): idx = state.active_layer[face] return state.layers[face][idx].image + def get_active_face_dimensions_pixels(self): + """Return the face dimensions for the active face in pixel coordinates. + + Computes the physical face size from the element's from/to positions. + Returns (width, height) in pixel space, or None if no model data. + """ + md = self.active_state.model_data + idx = self.selected_model_element + if md is None or not (0 <= idx < len(md.elements)): + return None + elem = md.elements[idx] + face = self.active_face + if face not in elem.faces: + return None + fp = elem.from_pos + tp = elem.to_pos + dx = abs(tp[0] - fp[0]) + dy = abs(tp[1] - fp[1]) + dz = abs(tp[2] - fp[2]) + if face in ("north", "south"): + fw, fh = dx, dy + elif face in ("east", "west"): + fw, fh = dz, dy + else: # up, down + fw, fh = dx, dz + scale = self.size / 16.0 + return (int(fw * scale), int(fh * scale)) + + def get_active_face_uv_pixels(self): + """Return the UV region for the active face in pixel coordinates. + + Returns (x1, y1, x2, y2) in pixel space, or None if unavailable. + """ + md = self.active_state.model_data + idx = self.selected_model_element + if md is None or not (0 <= idx < len(md.elements)): + return None + elem = md.elements[idx] + face = self.active_face + if face not in elem.faces: + return None + uv = elem.faces[face].get("uv", [0, 0, 16, 16]) + scale = self.size / 16.0 + return ( + int(uv[0] * scale), + int(uv[1] * scale), + int(uv[2] * scale), + int(uv[3] * scale), + ) + 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: @@ -735,9 +943,10 @@ def _load_state_from_generation(self, state_name, generation, texture_dir): block_state.source_paths[face] = filepath if parent_type == "cube" and parent: - parsed = parse_model_json(parent) + md, parsed = parse_model_json(parent) if parsed: block_state.geometry = parsed + block_state.model_data = md return block_state diff --git a/tools/texture-editor/main.py b/tools/texture-editor/main.py index 7cf920e..7f9dd85 100644 --- a/tools/texture-editor/main.py +++ b/tools/texture-editor/main.py @@ -5,7 +5,9 @@ import os import argparse -from PyQt6.QtWidgets import QApplication, QMainWindow, QSplitter, QVBoxLayout, QWidget +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QSplitter, QVBoxLayout, QWidget, QScrollArea, +) from PyQt6.QtCore import Qt from PyQt6.QtGui import QKeySequence, QShortcut @@ -16,6 +18,7 @@ from editor.face_panel import FacePanel from editor.tile_preview import TilePreview from editor.layer_panel import LayerPanel +from editor.model_panel import ModelPanel from editor.tools import ALL_TOOLS, ACTION_TOOLS, DragShapeTool, GradientTool @@ -23,7 +26,7 @@ class TextureEditor(QMainWindow): def __init__(self, size=32, block_id=None): super().__init__() self.setWindowTitle("Atlas Texture Editor") - self.resize(1400, 850) + self.showMaximized() self.model = TextureModel(size) @@ -56,9 +59,16 @@ def __init__(self, size=32, block_id=None): splitter.addWidget(middle) - # Right side: face panel + layer panel - right = QWidget() - right_layout = QVBoxLayout(right) + # Right side: face panel + layer panel + model panel in scrollable column + right_scroll = QScrollArea() + right_scroll.setWidgetResizable(True) + right_scroll.setFixedWidth(296) + right_scroll.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + right_inner = QWidget() + right_layout = QVBoxLayout(right_inner) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(0) @@ -68,8 +78,12 @@ def __init__(self, size=32, block_id=None): self.layer_panel = LayerPanel(self.model) right_layout.addWidget(self.layer_panel) - right.setFixedWidth(280) - splitter.addWidget(right) + self.model_panel = ModelPanel(self.model) + right_layout.addWidget(self.model_panel) + + right_layout.addStretch() + right_scroll.setWidget(right_inner) + splitter.addWidget(right_scroll) splitter.setSizes([500, 420, 280]) self.setCentralWidget(splitter) @@ -94,6 +108,9 @@ def __init__(self, size=32, block_id=None): self.color_picker.fill_clicked.connect( lambda: self._activate_action("Fill")) + # Connect model panel element selection to 3D preview wireframe + self.model.geometry_changed.connect(self._sync_wireframe_selection) + self._setup_shortcuts() # Load block if specified via CLI @@ -135,6 +152,11 @@ def _on_end_color_changed(self, color): if isinstance(tool, GradientTool): tool.end_color = color + def _sync_wireframe_selection(self): + self.preview.set_selected_element( + self.model_panel.get_selected_element_index() + ) + def _activate_action(self, name): tool = self._action_tools.get(name) if tool: @@ -153,13 +175,13 @@ def _setup_shortcuts(self): save_sc = QShortcut(QKeySequence("Ctrl+S"), self) save_sc.activated.connect(self.face_panel._save_face) - # Ctrl+Z: undo + # Ctrl+Z: undo (focus-based dispatch) undo_sc = QShortcut(QKeySequence("Ctrl+Z"), self) - undo_sc.activated.connect(self.model.undo) + undo_sc.activated.connect(self._undo) - # Ctrl+Shift+Z: redo + # Ctrl+Shift+Z: redo (focus-based dispatch) redo_sc = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) - redo_sc.activated.connect(self.model.redo) + redo_sc.activated.connect(self._redo) # 1-6: face selection for i in range(1, 7): @@ -179,6 +201,22 @@ def _setup_shortcuts(self): sc = QShortcut(QKeySequence(key), self) sc.activated.connect(lambda n=name: self._select_tool(n)) + def _model_panel_has_focus(self): + focus = QApplication.focusWidget() + return focus is not None and self.model_panel.isAncestorOf(focus) + + def _undo(self): + if self._model_panel_has_focus(): + self.model_panel.model_undo() + else: + self.model.undo() + + def _redo(self): + if self._model_panel_has_focus(): + self.model_panel.model_redo() + else: + self.model.redo() + def _decrease_brush(self): new_val = max(1, self.model.brush_size - 1) self.model.brush_size = new_val