Compare commits
No commits in common. "app/ventana-principal" and "main" have entirely different histories.
app/ventan
...
main
|
|
@ -22,11 +22,8 @@ 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*.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"""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())
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
"""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
157
app/ventana.py
|
|
@ -1,157 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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
16
uv.lock
|
|
@ -784,7 +784,6 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-qt" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
@ -801,7 +800,6 @@ 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]]
|
||||||
|
|
@ -1023,20 +1021,6 @@ 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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue