plano3d/motor/api.py

103 lines
3.8 KiB
Python

"""Contrato entre el motor de procesado y la aplicación.
Este módulo es lo único que la aplicación (interfaz y visor) conoce del motor.
Si hay que cambiar algo de aquí, se acuerda entre los dos y se anota en docs/decisiones.md,
porque rompe el trabajo del otro.
Uso desde la aplicación:
from motor.api import procesar, exportar, Opciones
resultado = procesar("pieza.dxf", Opciones())
if resultado.solido is not None:
exportar(resultado.solido, "pieza.step")
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
import cadquery as cq
class Estado(Enum):
OK = "ok" # pieza reconstruida y sin problemas
CON_AVISOS = "con_avisos" # pieza reconstruida, pero hay cosas que revisar
IMPOSIBLE = "imposible" # no se ha podido reconstruir; ver los avisos bloqueantes
class Gravedad(Enum):
INFO = "info" # dato informativo (p. ej. escala detectada)
AVISO = "aviso" # algo no cuadra, pero hay pieza
BLOQUEANTE = "bloqueante" # impide reconstruir; puede requerir un dato del usuario
@dataclass(frozen=True)
class Segmento2D:
"""Línea de una vista, en coordenadas reales de la pieza (mm)."""
x1: float
y1: float
x2: float
y2: float
oculta: bool = False
@dataclass
class Vista:
"""Geometría 2D de una vista, para mostrarla junto al modelo 3D."""
nombre: str # "alzado", "planta" o "perfil"
segmentos: list[Segmento2D] = field(default_factory=list)
@dataclass
class Referencia:
"""A qué parte del plano o de la pieza se refiere un aviso, para que el visor la resalte."""
vista: str | None = None # vista afectada, si la hay
segmento: Segmento2D | None = None # línea del plano afectada
punto: tuple[float, float, float] | None = None # punto de la pieza 3D (mm)
@dataclass
class Aviso:
codigo: str # identificador estable, p. ej. "SIN_COTAS", "PROYECCION_MIXTA"
mensaje: str # texto para el usuario, en español
gravedad: Gravedad
referencia: Referencia | None = None
@dataclass
class Opciones:
"""Datos que el usuario puede aportar cuando el motor los pide."""
escala: float | None = None # factor real/dibujo si el plano no trae escala (1:2 -> 2.0)
tolerancia: float = 1.0 # en unidades de dibujo
alternativa: int | None = None # si el plano es ambiguo, cuál de las piezas posibles se elige
@dataclass
class Resultado:
estado: Estado
solido: cq.Shape | None # pieza 3D en mm; None si el estado es IMPOSIBLE
avisos: list[Aviso] = field(default_factory=list)
vistas: dict[str, Vista] = field(default_factory=dict)
escala: float = 1.0 # factor aplicado (real/dibujo)
alternativas: int = 1 # número de piezas compatibles con el plano
def procesar(ruta_plano: str | Path, opciones: Opciones | None = None) -> Resultado:
"""Punto de entrada único del motor.
Mientras el motor real no esté listo devuelve un resultado simulado (motor/simulado.py),
para que la aplicación se pueda desarrollar en paralelo.
"""
from motor import simulado
return simulado.procesar(Path(ruta_plano), opciones or Opciones())
def exportar(solido: cq.Shape, ruta_salida: str | Path) -> None:
"""Guarda la pieza en STEP o STL según la extensión del archivo."""
ruta_salida = Path(ruta_salida)
if ruta_salida.suffix.lower() not in (".step", ".stp", ".stl"):
raise ValueError(f"Formato no admitido: {ruta_salida.suffix} (usa .step, .stp o .stl)")
cq.exporters.export(cq.Workplane().add(solido), str(ruta_salida))