fix(ui): 3D stale cache + node/waterline visual hierarchy

hull.py
  - add invalidate() — clears _surface NURBS cache on in-place
    offsets edit; fixes 3D viewer showing old geometry after drag

main_window.py
  - call hull.invalidate() before load_hull() in
    _on_offsets_edited_from_viewer so PyVista always rebuilds mesh
    from the updated offsets

viewer_lines.py
  - 4-layer drawing order: grid → control-net → hull-curves → nodes
  - nodes changed from 4px white-blue circles to 6px orange squares
    (_NODE_NORMAL #FF8000) — unambiguous visual language vs blue/green
    hull curves
  - _draw_cnet_bodyplan / _draw_cnet_planview helpers: thin muted
    control-net mesh (transverse + longitudinal edges) drawn between
    grid and bold hull curves, matching Maxsurf/DelftShip visual style
  - waterline reference lines made more muted (_GRID_WL dotted)
  - all old _GRID / _CPT_* references replaced with new palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:06:18 -04:00
parent 0f85935fc8
commit a4b8b03a59
3 changed files with 210 additions and 70 deletions
+10
View File
@@ -98,6 +98,16 @@ class Hull:
self._surface = self._build_surface() self._surface = self._build_surface()
return self._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: def _build_surface(self) -> LoftedSurface:
sections_data = [] sections_data = []
u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1] u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1]
+4 -1
View File
@@ -1366,7 +1366,10 @@ class MainWindow(QMainWindow):
hull = self._current_hull hull = self._current_hull
if hull is None: if hull is None:
return 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: if self._project is not None:
self._project.set_hull(hull) self._project.set_hull(hull)
# Actualizar vistas 2D SIN resetear zoom/pan # Actualizar vistas 2D SIN resetear zoom/pan
+192 -65
View File
@@ -37,24 +37,37 @@ from arshipdesign.core.hull import Hull
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Paleta del tema # Paleta del tema
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
_BG = QColor("#1a1d30") _BG = QColor("#131722")
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
_WATERLINE = QColor("#4da8ff") # Líneas de agua # ── Referencia / grilla (muy tenue, no compite con nada) ────────────────
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa) _GRID_STA = QColor(38, 55, 88, 80) # líneas de estación
_SECTION = QColor("#48a858") # Secciones de proa (verde) _GRID_WL = QColor(40, 60, 95, 70) # neas de agua (referencia)
_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") _AXIS = QColor("#3e4255")
# Puntos de control (malla editable) # ── Malla de control (control net) — thin, muted ───────────────────────
_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado # Capa intermedia entre grilla y curvas del casco.
_CPT_HOVER = QColor("#ffd700") # oro # Conecta los nodos formando el poliedro de control.
_CPT_DRAG = QColor("#ff5555") # rojo activo _CNET_TRAN = QColor(50, 80, 130, 140) # aristas transversales (a lo largo de estación)
_CPT_RADIUS = 4.0 # px en reposo _CNET_LONG = QColor(35, 90, 80, 110) # aristas longitudinales (a lo largo de LdA)
_CPT_HIT = 14.0 # px umbral de captura
# ── 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 # Clase base
@@ -266,19 +279,127 @@ class _BaseViewer(QWidget):
screen_pt: QPointF, screen_pt: QPointF,
idx: tuple[int, int], idx: tuple[int, int],
) -> None: ) -> 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: if idx == self._drag_idx:
color = _CPT_DRAG color = _NODE_DRAG
r = _CPT_RADIUS * 1.8 r = _NODE_R * 1.8
elif idx == self._hover_idx: elif idx == self._hover_idx:
color = _CPT_HOVER color = _NODE_HOVER
r = _CPT_RADIUS * 1.5 r = _NODE_R * 1.4
else: else:
color = _CPT_NORMAL color = _NODE_NORMAL
r = _CPT_RADIUS r = _NODE_R
p.setPen(QPen(color.darker(130), 1)) from PySide6.QtCore import QRectF
p.setPen(QPen(color.darker(180), 1))
p.setBrush(QBrush(color)) 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 T = self._hull.draft
n = ot.n_stations n = ot.n_stations
# ── Líneas de agua — grilla horizontal ────────────────────────
x_max = ot.max_half_breadth * 1.15 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): for j, z in enumerate(ot.z_waterlines):
is_design = abs(z - T) < 1e-6 is_design = abs(z - T) < 1e-6
if is_design: 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: 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)) p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z))
# Línea de flotación de diseño (más visible) # Ejes
p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine)) p.setPen(QPen(_AXIS, 1.0))
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) 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): for i in range(n):
is_fwd = i >= n // 2 is_fwd = i >= n // 2
is_mid = i == n // 2 is_mid = i == n // 2
if is_mid: if is_mid:
pen = QPen(_MIDSHIP, 2.5) pen = QPen(_MIDSHIP, 2.2)
elif is_fwd: elif is_fwd:
pen = QPen(_SECTION, 1.4) pen = QPen(_SECTION, 1.5)
else: else:
pen = QPen(_SECTION_AFT, 1.4) pen = QPen(_SECTION_AFT, 1.5)
p.setPen(pen) p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
y_arr = ot.data[i, :] y_arr = ot.data[i, :]
z_arr = ot.z_waterlines z_arr = ot.z_waterlines
sign = 1.0 if is_fwd else -1.0 sign = 1.0 if is_fwd else -1.0
@@ -390,18 +519,14 @@ class BodyPlanViewer(_BaseViewer):
path.moveTo(pt) path.moveTo(pt)
else: else:
path.lineTo(pt) path.lineTo(pt)
# Cerrar en quilla
path.lineTo(self._w2s(0.0, 0.0)) path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path) p.drawPath(path)
# ── Ejes ────────────────────────────────────────────────────── # Flotación de diseño (encima de todo lo anterior)
p.setPen(QPen(_AXIS, 1)) p.setPen(QPen(_WL_DESIGN, 1.8, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía
# ── Puntos de control ───────────────────────────────────────── # ══ CAPA 4: Nodos (cuadrados naranjas — siempre encima) ═══════
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for i in range(n): for i in range(n):
for j in range(ot.n_waterlines): for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_control_point(p, self._screen_pt(i, j), (i, j))
@@ -464,7 +589,7 @@ class ProfileViewer(_BaseViewer):
Lpp = self._hull.lpp Lpp = self._hull.lpp
# ── Grilla de estaciones ─────────────────────────────────────── # ── 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: for x in ot.x_stations:
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2)) p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2))
@@ -577,45 +702,47 @@ class PlanViewer(_BaseViewer):
ot = self._hull.offsets ot = self._hull.offsets
T = self._hull.draft T = self._hull.draft
n_wl = ot.n_waterlines 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): for j in range(n_wl):
z = ot.z_waterlines[j] z = ot.z_waterlines[j]
is_design = abs(z - T) < 1e-6
frac = j / max(n_wl - 1, 1) frac = j / max(n_wl - 1, 1)
is_design = abs(z - T) < 1e-6
if is_design: if is_design:
color = QColor(_WL_DESIGN) color = QColor(_WL_DESIGN)
color.setAlphaF(1.0) width = 2.2
width = 2.0
else: else:
color = QColor(_WATERLINE) color = QColor(_WATERLINE)
color.setAlphaF(0.30 + 0.55 * frac) color.setAlphaF(0.40 + 0.50 * frac)
width = 0.9 width = 1.1
p.setPen(QPen(color, width)) p.setPen(QPen(color, width))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath() path = QPainterPath()
x_arr = ot.x_stations for i, (x, y) in enumerate(zip(ot.x_stations, ot.data[:, j])):
y_arr = ot.data[:, j]
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
pt = self._w2s(x, y) pt = self._w2s(x, y)
if k == 0: if i == 0:
path.moveTo(pt) path.moveTo(pt)
else: else:
path.lineTo(pt) path.lineTo(pt)
p.drawPath(path) p.drawPath(path)
# ── Eje de crujía ───────────────────────────────────────────── # ══ CAPA 4: Nodos (cuadrados naranjas) ════════════════════════
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 ─────────────────────────────────────────
for i in range(ot.n_stations): for i in range(ot.n_stations):
for j in range(n_wl): for j in range(n_wl):
self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_control_point(p, self._screen_pt(i, j), (i, j))