Añade la ventana principal con apertura de planos #12

Open
joel.2627 wants to merge 1 commits from app/ventana-principal into main
7 changed files with 274 additions and 0 deletions

View File

@ -22,8 +22,11 @@ Requisitos: [uv](https://docs.astral.sh/uv/) y Git. CadQuery necesita Python 3.1
```bash ```bash
uv sync uv sync
uv run pytest 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 ## Formas de trabajo
Ver [CONTRIBUTING.md](CONTRIBUTING.md): ramas, *pull requests*, revisiones e *issues*. Ver [CONTRIBUTING.md](CONTRIBUTING.md): ramas, *pull requests*, revisiones e *issues*.

18
app/__main__.py Normal file
View File

@ -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())

33
app/procesado.py Normal file
View File

@ -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)

157
app/ventana.py Normal file
View File

@ -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)

46
pruebas/test_ventana.py Normal file
View File

@ -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

View File

@ -18,6 +18,7 @@ dependencies = [
dev = [ dev = [
"pytest>=8", "pytest>=8",
"pyinstaller>=6", "pyinstaller>=6",
"pytest-qt>=4.5.0",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

16
uv.lock
View File

@ -784,6 +784,7 @@ dependencies = [
dev = [ dev = [
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-qt" },
] ]
[package.metadata] [package.metadata]
@ -800,6 +801,7 @@ requires-dist = [
dev = [ dev = [
{ name = "pyinstaller", specifier = ">=6" }, { name = "pyinstaller", specifier = ">=6" },
{ name = "pytest", specifier = ">=8" }, { name = "pytest", specifier = ">=8" },
{ name = "pytest-qt", specifier = ">=4.5.0" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "3.0.0" version = "3.0.0"