From e43dd5b5c487b063ee440890802243fa12d3aeea Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 4 Nov 2026 16:41:19 +0100 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20la=20ventana=20principal=20con=20a?= =?UTF-8?q?pertura=20de=20planos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La ventana abre planos DXF desde el menú (Ctrl+O) o arrastrándolos, los procesa con motor.api en un hilo aparte para no bloquear la interfaz, y muestra el estado del resultado y la lista de avisos con su gravedad. Incluye pruebas con pytest-qt sobre el motor simulado. --- README.md | 3 + app/__main__.py | 18 +++++ app/procesado.py | 33 +++++++++ app/ventana.py | 157 ++++++++++++++++++++++++++++++++++++++++ pruebas/test_ventana.py | 46 ++++++++++++ pyproject.toml | 1 + uv.lock | 16 ++++ 7 files changed, 274 insertions(+) create mode 100644 app/__main__.py create mode 100644 app/procesado.py create mode 100644 app/ventana.py create mode 100644 pruebas/test_ventana.py diff --git a/README.md b/README.md index b4721f4..34a54e3 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ Requisitos: [uv](https://docs.astral.sh/uv/) y Git. CadQuery necesita Python 3.1 ```bash uv sync uv run pytest +uv run python -m app ``` +Mientras el motor real está en desarrollo, la aplicación usa el motor simulado y muestra siempre la pieza CUBE01. + ## Formas de trabajo Ver [CONTRIBUTING.md](CONTRIBUTING.md): ramas, *pull requests*, revisiones e *issues*. diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..298aec4 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,18 @@ +"""Punto de entrada de la aplicación: uv run python -m app""" +import sys + +from PySide6.QtWidgets import QApplication + +from app.ventana import VentanaPrincipal + + +def main() -> int: + aplicacion = QApplication(sys.argv) + aplicacion.setApplicationName("Plano3D") + ventana = VentanaPrincipal() + ventana.show() + return aplicacion.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/procesado.py b/app/procesado.py new file mode 100644 index 0000000..8e3182b --- /dev/null +++ b/app/procesado.py @@ -0,0 +1,33 @@ +"""Ejecución del motor en segundo plano. + +El motor puede tardar varios segundos en reconstruir una pieza. Si se llamara desde el hilo +de la interfaz, la ventana se quedaría congelada mientras tanto, así que se ejecuta en un +QThread y el resultado vuelve a la ventana mediante señales. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from motor.api import Opciones, procesar + + +class HiloProcesado(QThread): + """Procesa un plano con motor.api.procesar sin bloquear la interfaz.""" + + terminado = Signal(object) # motor.api.Resultado + fallido = Signal(str) # mensaje de error para el usuario + + def __init__(self, ruta: Path, opciones: Opciones | None = None, parent=None): + super().__init__(parent) + self.ruta = ruta + self.opciones = opciones or Opciones() + + def run(self) -> None: + try: + resultado = procesar(self.ruta, self.opciones) + except Exception as error: # cualquier fallo del motor se muestra al usuario + self.fallido.emit(f"{type(error).__name__}: {error}") + else: + self.terminado.emit(resultado) diff --git a/app/ventana.py b/app/ventana.py new file mode 100644 index 0000000..c3d1339 --- /dev/null +++ b/app/ventana.py @@ -0,0 +1,157 @@ +"""Ventana principal de Plano3D: abrir un plano y mostrar el resultado del motor.""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QMimeData, Qt +from PySide6.QtGui import QAction, QDragEnterEvent, QDropEvent, QKeySequence +from PySide6.QtWidgets import ( + QFileDialog, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QStackedWidget, + QStyle, + QVBoxLayout, + QWidget, +) + +from app.procesado import HiloProcesado +from motor.api import Estado, Gravedad, Resultado + +EXTENSIONES = {".dxf"} # se ampliará con .dwg y .pdf cuando el motor los admita + +TEXTO_ESTADO = { + Estado.OK: "Pieza reconstruida", + Estado.CON_AVISOS: "Pieza reconstruida con avisos", + Estado.IMPOSIBLE: "No se ha podido reconstruir la pieza", +} + +ICONO_GRAVEDAD = { + Gravedad.INFO: QStyle.StandardPixmap.SP_MessageBoxInformation, + Gravedad.AVISO: QStyle.StandardPixmap.SP_MessageBoxWarning, + Gravedad.BLOQUEANTE: QStyle.StandardPixmap.SP_MessageBoxCritical, +} + + +class VentanaPrincipal(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Plano3D") + self.resize(1000, 700) + self.setAcceptDrops(True) + + self.resultado: Resultado | None = None + self.ruta_actual: Path | None = None + self._hilo: HiloProcesado | None = None + + self._crear_menu() + self._crear_contenido() + self.statusBar().showMessage("Listo") + + # ------------------------------------------------------------------ construcción + def _crear_menu(self) -> None: + self.accion_abrir = QAction("&Abrir plano…", self) + self.accion_abrir.setShortcut(QKeySequence.StandardKey.Open) + self.accion_abrir.triggered.connect(self.elegir_plano) + + accion_salir = QAction("&Salir", self) + accion_salir.setShortcut(QKeySequence.StandardKey.Quit) + accion_salir.triggered.connect(self.close) + + menu_archivo = self.menuBar().addMenu("&Archivo") + menu_archivo.addAction(self.accion_abrir) + menu_archivo.addSeparator() + menu_archivo.addAction(accion_salir) + + def _crear_contenido(self) -> None: + # Página 0: indicación inicial. Página 1: resultado del plano. + self.paginas = QStackedWidget() + + self.etiqueta_inicio = QLabel("Arrastra un plano DXF a esta ventana\no usa Archivo › Abrir plano (Ctrl+O)") + self.etiqueta_inicio.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.paginas.addWidget(self.etiqueta_inicio) + + pagina_resultado = QWidget() + disposicion = QVBoxLayout(pagina_resultado) + self.etiqueta_archivo = QLabel() + self.etiqueta_estado = QLabel() + fuente = self.etiqueta_estado.font() + fuente.setPointSize(fuente.pointSize() + 2) + fuente.setBold(True) + self.etiqueta_estado.setFont(fuente) + self.lista_avisos = QListWidget() + self.lista_avisos.setWordWrap(True) # los avisos largos se parten en varias líneas + self.lista_avisos.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + disposicion.addWidget(self.etiqueta_archivo) + disposicion.addWidget(self.etiqueta_estado) + disposicion.addWidget(QLabel("Informe del plano:")) + disposicion.addWidget(self.lista_avisos) + self.paginas.addWidget(pagina_resultado) + + self.setCentralWidget(self.paginas) + + # ------------------------------------------------------------------ abrir planos + def elegir_plano(self) -> None: + ruta, _ = QFileDialog.getOpenFileName(self, "Abrir plano", "", "Planos DXF (*.dxf)") + if ruta: + self.cargar_plano(Path(ruta)) + + def cargar_plano(self, ruta: Path) -> None: + """Lanza el procesado de un plano. El resultado llega por _mostrar_resultado.""" + if self._hilo is not None and self._hilo.isRunning(): + return # ya hay un plano en proceso + if ruta.suffix.lower() not in EXTENSIONES: + QMessageBox.warning(self, "Formato no admitido", + f"No se puede abrir «{ruta.name}»: de momento solo se admiten planos DXF.") + return + + self.ruta_actual = ruta + self.accion_abrir.setEnabled(False) + self.statusBar().showMessage(f"Procesando {ruta.name}…") + self._hilo = HiloProcesado(ruta, parent=self) + self._hilo.terminado.connect(self._mostrar_resultado) + self._hilo.fallido.connect(self._mostrar_error) + self._hilo.finished.connect(lambda: self.accion_abrir.setEnabled(True)) + self._hilo.start() + + # Arrastrar y soltar: se acepta solo si se arrastra un único archivo con extensión admitida. + def dragEnterEvent(self, evento: QDragEnterEvent) -> None: + if self._ruta_arrastrada(evento.mimeData()) is not None: + evento.acceptProposedAction() + + def dropEvent(self, evento: QDropEvent) -> None: + ruta = self._ruta_arrastrada(evento.mimeData()) + if ruta is not None: + self.cargar_plano(ruta) + + @staticmethod + def _ruta_arrastrada(datos: QMimeData) -> Path | None: + urls = datos.urls() + if len(urls) != 1 or not urls[0].isLocalFile(): + return None + ruta = Path(urls[0].toLocalFile()) + return ruta if ruta.suffix.lower() in EXTENSIONES else None + + # ------------------------------------------------------------------ resultado + def _mostrar_resultado(self, resultado: Resultado) -> None: + self.resultado = resultado + self.etiqueta_archivo.setText(f"Plano: {self.ruta_actual.name} · Escala aplicada: ×{resultado.escala:g}") + self.etiqueta_estado.setText(TEXTO_ESTADO[resultado.estado]) + + self.lista_avisos.clear() + for aviso in resultado.avisos: + elemento = QListWidgetItem(self.style().standardIcon(ICONO_GRAVEDAD[aviso.gravedad]), aviso.mensaje) + elemento.setToolTip(aviso.codigo) + self.lista_avisos.addItem(elemento) + if not resultado.avisos: + self.lista_avisos.addItem("Sin observaciones.") + + self.paginas.setCurrentIndex(1) + self.statusBar().showMessage("Procesado terminado", 5000) + + def _mostrar_error(self, mensaje: str) -> None: + self.statusBar().showMessage("Error al procesar el plano", 5000) + QMessageBox.critical(self, "Error al procesar el plano", mensaje) diff --git a/pruebas/test_ventana.py b/pruebas/test_ventana.py new file mode 100644 index 0000000..32526c7 --- /dev/null +++ b/pruebas/test_ventana.py @@ -0,0 +1,46 @@ +"""Pruebas de la ventana principal con el motor simulado.""" +from pathlib import Path + +from PySide6.QtCore import QMimeData, QUrl + +from app.ventana import VentanaPrincipal + +CUBE01 = Path(__file__).parent / "catalogo" / "CUBE01" / "plano.dxf" + + +def test_arranca_en_la_pagina_de_inicio(qtbot): + ventana = VentanaPrincipal() + qtbot.addWidget(ventana) + assert ventana.paginas.currentIndex() == 0 + + +def test_cargar_cube01_muestra_estado_y_avisos(qtbot): + ventana = VentanaPrincipal() + qtbot.addWidget(ventana) + ventana.cargar_plano(CUBE01) + qtbot.waitUntil(lambda: ventana.resultado is not None, timeout=30000) + + assert ventana.paginas.currentIndex() == 1 + assert ventana.etiqueta_estado.text() == "Pieza reconstruida con avisos" + codigos = [ventana.lista_avisos.item(i).toolTip() for i in range(ventana.lista_avisos.count())] + assert "SIN_COTAS" in codigos and "PROYECCION_MIXTA" in codigos + + +def test_rechaza_formatos_no_admitidos(qtbot, monkeypatch): + ventana = VentanaPrincipal() + qtbot.addWidget(ventana) + avisos = [] + monkeypatch.setattr("app.ventana.QMessageBox.warning", lambda *args: avisos.append(args)) + ventana.cargar_plano(Path("pieza.pdf")) + assert avisos and ventana.ruta_actual is None + + +def test_arrastrar_solo_acepta_un_dxf(): + def arrastrados(*rutas): + datos = QMimeData() + datos.setUrls([QUrl.fromLocalFile(str(r)) for r in rutas]) + return datos + + assert VentanaPrincipal._ruta_arrastrada(arrastrados(CUBE01)) == CUBE01 + assert VentanaPrincipal._ruta_arrastrada(arrastrados("pieza.pdf")) is None + assert VentanaPrincipal._ruta_arrastrada(arrastrados(CUBE01, CUBE01)) is None diff --git a/pyproject.toml b/pyproject.toml index 6d76fa2..5eaf55a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ dev = [ "pytest>=8", "pyinstaller>=6", + "pytest-qt>=4.5.0", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index c6c1be8..3987623 100644 --- a/uv.lock +++ b/uv.lock @@ -784,6 +784,7 @@ dependencies = [ dev = [ { name = "pyinstaller" }, { name = "pytest" }, + { name = "pytest-qt" }, ] [package.metadata] @@ -800,6 +801,7 @@ requires-dist = [ dev = [ { name = "pyinstaller", specifier = ">=6" }, { name = "pytest", specifier = ">=8" }, + { name = "pytest-qt", specifier = ">=4.5.0" }, ] [[package]] @@ -1021,6 +1023,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-qt" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, +] + [[package]] name = "python-dateutil" version = "3.0.0"