diff --git a/arshipdesign/core/hull.py b/arshipdesign/core/hull.py index fe1441d..7ddc152 100644 --- a/arshipdesign/core/hull.py +++ b/arshipdesign/core/hull.py @@ -98,6 +98,16 @@ class Hull: self._surface = self._build_surface() return self._surface + def invalidate(self) -> None: + """Invalida la caché de la superficie NURBS. + + Llamar siempre que se modifiquen los offsets in-place + (p.ej. arrastre interactivo en los visores 2D) para que la + próxima llamada a ``surface`` o ``to_mesh`` reconstruya la + geometría desde los datos actualizados. + """ + self._surface = None + def _build_surface(self) -> LoftedSurface: sections_data = [] u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1] diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index 5631d35..faf47e9 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -1366,7 +1366,10 @@ class MainWindow(QMainWindow): hull = self._current_hull if hull is None: return - # hull.offsets ya fue modificado in-place durante el drag + # hull.offsets ya fue modificado in-place durante el drag. + # Invalidar caché NURBS para que to_mesh() reconstruya desde los + # offsets editados y no devuelva la geometría anterior. + hull.invalidate() if self._project is not None: self._project.set_hull(hull) # Actualizar vistas 2D SIN resetear zoom/pan diff --git a/arshipdesign/ui/widgets/viewer_lines.py b/arshipdesign/ui/widgets/viewer_lines.py index 744abdb..7080714 100644 --- a/arshipdesign/ui/widgets/viewer_lines.py +++ b/arshipdesign/ui/widgets/viewer_lines.py @@ -37,24 +37,37 @@ from arshipdesign.core.hull import Hull # ───────────────────────────────────────────────────────────────────────────── # Paleta del tema # ───────────────────────────────────────────────────────────────────────────── -_BG = QColor("#1a1d30") -_GRID = QColor("#2a3060") # Estaciones (muy tenue) -_WATERLINE = QColor("#4da8ff") # Líneas de agua -_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa) -_SECTION = QColor("#48a858") # Secciones de proa (verde) -_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul) -_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado) -_DECK = QColor("#8868c8") # Línea de cubierta (púrpura) -_KEEL = QColor("#e06060") # Quilla (rojo suave) -_TEXT = QColor("#7a8ba8") -_AXIS = QColor("#3e4255") +_BG = QColor("#131722") -# Puntos de control (malla editable) -_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado -_CPT_HOVER = QColor("#ffd700") # oro -_CPT_DRAG = QColor("#ff5555") # rojo activo -_CPT_RADIUS = 4.0 # px en reposo -_CPT_HIT = 14.0 # px umbral de captura +# ── Referencia / grilla (muy tenue, no compite con nada) ──────────────── +_GRID_STA = QColor(38, 55, 88, 80) # líneas de estación +_GRID_WL = QColor(40, 60, 95, 70) # líneas de agua (referencia) +_AXIS = QColor("#3e4255") + +# ── Malla de control (control net) — thin, muted ─────────────────────── +# Capa intermedia entre grilla y curvas del casco. +# Conecta los nodos formando el poliedro de control. +_CNET_TRAN = QColor(50, 80, 130, 140) # aristas transversales (a lo largo de estación) +_CNET_LONG = QColor(35, 90, 80, 110) # aristas longitudinales (a lo largo de LdA) + +# ── Curvas del casco (sobre la malla) ────────────────────────────────── +_WATERLINE = QColor("#2a82c0") # líneas de agua +_WL_DESIGN = QColor("#00ccff") # flotación de diseño +_SECTION = QColor("#3a9e52") # secciones de proa +_SECTION_AFT = QColor("#2a78c0") # secciones de popa +_MIDSHIP = QColor("#d89020") # cuaderna maestra +_DECK = QColor("#7058b8") # cubierta +_KEEL = QColor("#c85858") # quilla +_TEXT = QColor("#7a8ba8") + +# ── Nodos (handles) — encima de todo, color único: NARANJA ───────────── +# El naranja no existe en ninguna curva del casco → cero ambigüedad. +_NODE_NORMAL = QColor("#FF8000") # naranja: estado de reposo +_NODE_HOVER = QColor("#FFD700") # oro: hover +_NODE_DRAG = QColor("#FF2020") # rojo vivo: arrastrando +_NODE_R = 4.5 # px semi-lado del cuadrado +_CPT_HIT = 16.0 # px umbral de captura (alias legacy) +_CPT_RADIUS = _NODE_R # alias legacy # ───────────────────────────────────────────────────────────────────────────── # Clase base @@ -266,19 +279,127 @@ class _BaseViewer(QWidget): screen_pt: QPointF, idx: tuple[int, int], ) -> None: - """Dibuja un punto de control con color según estado.""" + """Dibuja un nodo de control como cuadrado naranja sobre las curvas. + + El naranja distingue inequívocamente los nodos de cualquier línea del + casco (azul/verde/dorado). La forma cuadrada evoca el vocabulario de + las herramientas CAD (Maxsurf, DelftShip). + """ if idx == self._drag_idx: - color = _CPT_DRAG - r = _CPT_RADIUS * 1.8 + color = _NODE_DRAG + r = _NODE_R * 1.8 elif idx == self._hover_idx: - color = _CPT_HOVER - r = _CPT_RADIUS * 1.5 + color = _NODE_HOVER + r = _NODE_R * 1.4 else: - color = _CPT_NORMAL - r = _CPT_RADIUS - p.setPen(QPen(color.darker(130), 1)) + color = _NODE_NORMAL + r = _NODE_R + from PySide6.QtCore import QRectF + p.setPen(QPen(color.darker(180), 1)) p.setBrush(QBrush(color)) - p.drawEllipse(screen_pt, r, r) + p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2)) + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers: malla de control (control net) +# ───────────────────────────────────────────────────────────────────────────── + +def _draw_cnet_bodyplan(p: QPainter, ot, w2s_fn) -> None: + """Dibuja la malla de control en el Body Plan. + + Capa visual entre la grilla de referencia y las curvas del casco: + • Aristas transversales — polilínea de control de cada sección + (equal to the section control polyline, muted, drawn BEFORE the + actual hull-curve so the colored curve reads on top of it). + • Aristas longitudinales — segmentos horizontales a la altura de cada + línea de agua, conectando todos los nodos de esa LdA en ambas bandas. + Permiten ver cómo varía la manga de proa a popa en cada calado. + """ + n_sta = ot.n_stations + n_wl = ot.n_waterlines + + # ── Aristas transversales (a lo largo de cada sección) ──────────── + pen_t = QPen(_CNET_TRAN, 0.8, Qt.PenStyle.SolidLine) + p.setPen(pen_t) + p.setBrush(Qt.BrushStyle.NoBrush) + for i in range(n_sta): + sign = 1.0 if i >= n_sta // 2 else -1.0 + path = QPainterPath() + for k in range(n_wl): + pt = w2s_fn(sign * ot.data[i, k], ot.z_waterlines[k]) + if k == 0: + path.moveTo(pt) + else: + path.lineTo(pt) + # Cerrar al eje de crujía en la quilla + path.lineTo(w2s_fn(0.0, 0.0)) + p.drawPath(path) + + # ── Aristas longitudinales (a lo largo de cada LdA) ─────────────── + # Para cada LdA j: una polilínea a través de todas las estaciones, en + # cada banda por separado (proa=+y, popa=−y). Se ve como un arco a + # la altura z[j], mostrando la variación de manga longitudinalmente. + pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine) + p.setPen(pen_l) + for j in range(n_wl): + z = ot.z_waterlines[j] + # Banda de proa (estribor, sign=+1) + path_fwd = QPainterPath() + path_aft = QPainterPath() + for i in range(n_sta): + sign = 1.0 if i >= n_sta // 2 else -1.0 + pt = w2s_fn(sign * ot.data[i, j], z) + if i == 0: + path_aft.moveTo(pt) + elif i == n_sta // 2: + path_fwd.moveTo(pt) + if i < n_sta // 2: + path_aft.lineTo(pt) + else: + path_fwd.lineTo(pt) + p.drawPath(path_fwd) + p.drawPath(path_aft) + + +def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None: + """Dibuja la malla de control en la Vista de Planta. + + • Aristas longitudinales — waterlines (conectan todas las estaciones + en una LdA = las curvas de contorno, dibujadas muted ANTES de las + curvas reales). + • Aristas transversales — polilínea vertical por estación, + conectando los nodos de esa estación a lo largo de todas las LdA. + Muestra cómo cambia la manga con el calado para cada estación. + """ + n_sta = ot.n_stations + n_wl = ot.n_waterlines + + # ── Aristas longitudinales (contornos de LdA) ───────────────────── + pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine) + p.setPen(pen_l) + p.setBrush(Qt.BrushStyle.NoBrush) + for j in range(n_wl): + path = QPainterPath() + for i in range(n_sta): + pt = w2s_fn(ot.x_stations[i], ot.data[i, j]) + if i == 0: + path.moveTo(pt) + else: + path.lineTo(pt) + p.drawPath(path) + + # ── Aristas transversales (polilínea de sección en planta) ───────── + pen_t = QPen(_CNET_TRAN, 0.7, Qt.PenStyle.SolidLine) + p.setPen(pen_t) + for i in range(n_sta): + path = QPainterPath() + for j in range(n_wl): + pt = w2s_fn(ot.x_stations[i], ot.data[i, j]) + if j == 0: + path.moveTo(pt) + else: + path.lineTo(pt) + p.drawPath(path) # ───────────────────────────────────────────────────────────────────────────── @@ -352,33 +473,41 @@ class BodyPlanViewer(_BaseViewer): T = self._hull.draft n = ot.n_stations - # ── Líneas de agua — grilla horizontal ──────────────────────── x_max = ot.max_half_breadth * 1.15 + + # ══ CAPA 1: Grilla de referencia (tenue, sin competir) ════════ + # Líneas de agua horizontales — referencia de altura for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: - p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine)) + p.setPen(QPen(_WL_DESIGN.darker(200), 0.8, Qt.PenStyle.DashLine)) else: - p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine)) + p.setPen(QPen(_GRID_WL, 0.5, Qt.PenStyle.DotLine)) p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z)) - # Línea de flotación de diseño (más visible) - p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine)) - p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) + # Ejes + p.setPen(QPen(_AXIS, 1.0)) + p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) + p.setPen(QPen(_AXIS, 0.7, Qt.PenStyle.DashLine)) + p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.18)) - # ── Secciones ───────────────────────────────────────────────── + # ══ CAPA 2: Malla de control (control net — thin, muted) ══════ + _draw_cnet_bodyplan(p, ot, self._w2s) + + # ══ CAPA 3: Curvas del casco (bold, saturated) ════════════════ for i in range(n): is_fwd = i >= n // 2 is_mid = i == n // 2 if is_mid: - pen = QPen(_MIDSHIP, 2.5) + pen = QPen(_MIDSHIP, 2.2) elif is_fwd: - pen = QPen(_SECTION, 1.4) + pen = QPen(_SECTION, 1.5) else: - pen = QPen(_SECTION_AFT, 1.4) + pen = QPen(_SECTION_AFT, 1.5) p.setPen(pen) + p.setBrush(Qt.BrushStyle.NoBrush) y_arr = ot.data[i, :] z_arr = ot.z_waterlines sign = 1.0 if is_fwd else -1.0 @@ -390,18 +519,14 @@ class BodyPlanViewer(_BaseViewer): path.moveTo(pt) else: path.lineTo(pt) - # Cerrar en quilla path.lineTo(self._w2s(0.0, 0.0)) p.drawPath(path) - # ── Ejes ────────────────────────────────────────────────────── - p.setPen(QPen(_AXIS, 1)) - p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla - p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) - p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía + # Flotación de diseño (encima de todo lo anterior) + p.setPen(QPen(_WL_DESIGN, 1.8, Qt.PenStyle.DashLine)) + p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) - # ── Puntos de control ───────────────────────────────────────── - p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + # ══ CAPA 4: Nodos (cuadrados naranjas — siempre encima) ═══════ for i in range(n): for j in range(ot.n_waterlines): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) @@ -464,7 +589,7 @@ class ProfileViewer(_BaseViewer): Lpp = self._hull.lpp # ── Grilla de estaciones ─────────────────────────────────────── - p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine)) + p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine)) for x in ot.x_stations: p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2)) @@ -574,48 +699,50 @@ class PlanViewer(_BaseViewer): p.end() return - ot = self._hull.offsets - T = self._hull.draft + ot = self._hull.offsets + T = self._hull.draft n_wl = ot.n_waterlines + y_max = ot.max_half_breadth - # ── Líneas de agua como contornos ───────────────────────────── + # ══ CAPA 1: Grilla de referencia ══════════════════════════════ + # Estaciones — líneas verticales tenues + p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine)) + for x in ot.x_stations: + p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15)) + + # Eje de crujía + p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) + p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0)) + + # ══ CAPA 2: Malla de control ══════════════════════════════════ + _draw_cnet_planview(p, ot, self._w2s) + + # ══ CAPA 3: Curvas del casco (waterlines como contornos) ══════ for j in range(n_wl): - z = ot.z_waterlines[j] - is_design = abs(z - T) < 1e-6 + z = ot.z_waterlines[j] frac = j / max(n_wl - 1, 1) + is_design = abs(z - T) < 1e-6 if is_design: color = QColor(_WL_DESIGN) - color.setAlphaF(1.0) - width = 2.0 + width = 2.2 else: color = QColor(_WATERLINE) - color.setAlphaF(0.30 + 0.55 * frac) - width = 0.9 + color.setAlphaF(0.40 + 0.50 * frac) + width = 1.1 p.setPen(QPen(color, width)) + p.setBrush(Qt.BrushStyle.NoBrush) path = QPainterPath() - x_arr = ot.x_stations - y_arr = ot.data[:, j] - for k, (x, y) in enumerate(zip(x_arr, y_arr)): + for i, (x, y) in enumerate(zip(ot.x_stations, ot.data[:, j])): pt = self._w2s(x, y) - if k == 0: + if i == 0: path.moveTo(pt) else: path.lineTo(pt) p.drawPath(path) - # ── Eje de crujía ───────────────────────────────────────────── - p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) - p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0)) - - # ── Estaciones ──────────────────────────────────────────────── - p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine)) - y_max = ot.max_half_breadth - for x in ot.x_stations: - p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15)) - - # ── Puntos de control ───────────────────────────────────────── + # ══ CAPA 4: Nodos (cuadrados naranjas) ════════════════════════ for i in range(ot.n_stations): for j in range(n_wl): self._draw_control_point(p, self._screen_pt(i, j), (i, j))