From 9a0852636186820cc3ec51862cfdd2d661b0d30b Mon Sep 17 00:00:00 2001 From: Alvaro Romero Date: Fri, 3 Jul 2026 12:23:25 -0400 Subject: [PATCH] feat: AR-Shipdesign initial commit --- .claude/settings.local.json | 27 +- LICENSE.txt | 4 +- README.md | 2 +- arshipdesign/core/hull.py | 360 +++- arshipdesign/core/offsets.py | 38 +- arshipdesign/parametric/series60.py | 23 +- arshipdesign/parametric/wizard_cruiser.py | 23 +- arshipdesign/parametric/wizard_planing.py | 23 +- .../parametric/wizard_sailing_mono.py | 25 +- arshipdesign/parametric/wizard_workboat.py | 25 +- arshipdesign/ui/icons.py | 1286 ++++++++++++ arshipdesign/ui/main_window.py | 646 ++++-- arshipdesign/ui/widgets/offsets_editor.py | 8 +- arshipdesign/ui/widgets/viewer_3d.py | 60 +- arshipdesign/ui/widgets/viewer_lines.py | 1726 +++++++++++++++-- docs/BITACORA.md | 549 ++++++ 16 files changed, 4431 insertions(+), 394 deletions(-) create mode 100644 arshipdesign/ui/icons.py create mode 100644 docs/BITACORA.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 10385a0..e645094 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,32 @@ "Bash(sort -t/ -k1)", "Bash(xargs -I{} wc -l {})", "Bash(mkdir -p \"D:/Proyectos Software/AR-Shipdesign/.claude\")", - "Bash(python create_stubs.py)" + "Bash(python create_stubs.py)", + "WebSearch", + "WebFetch(domain:www.delftship.net)", + "WebFetch(domain:charlestonmarineconsulting.com)", + "WebFetch(domain:www.bentley.com)", + "WebFetch(domain:maxsurf.net)", + "WebFetch(domain:www.boatdesign.net)", + "WebFetch(domain:bentley.aufieroinformatica.com)", + "WebFetch(domain:www.kastenmarine.com)", + "WebFetch(domain:cyberships.wordpress.com)", + "WebFetch(domain:mycourses.aalto.fi)", + "WebFetch(domain:studylib.net)", + "WebFetch(domain:sourceforge.net)", + "WebFetch(domain:forum.delftship.net)", + "WebFetch(domain:manualzz.com)", + "WebFetch(domain:www.scribd.com)", + "WebFetch(domain:zdocs.tips)", + "WebFetch(domain:www.coursehero.com)", + "WebFetch(domain:nanopdf.com)", + "WebFetch(domain:pdfcoffee.com)", + "WebFetch(domain:www.kashti.ir)", + "Skill(update-config)", + "Skill(update-config:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:github.com)", + "Bash(mkdir -p ~/.claude/session-data && date)" ] } } diff --git a/LICENSE.txt b/LICENSE.txt index bec4483..d0ff112 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,9 +1,9 @@ AR-ShipDesign — Software de Diseño Naval -Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados. +Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados. LICENCIA PROPIETARIA -Este software y su código fuente son propiedad exclusiva de Álvaro Rodríguez. +Este software y su código fuente son propiedad exclusiva de Álvaro Romero. Queda prohibida su copia, distribución, modificación, ingeniería inversa o uso comercial sin autorización expresa y por escrito del titular. diff --git a/README.md b/README.md index b42715c..164d34a 100644 --- a/README.md +++ b/README.md @@ -45,5 +45,5 @@ python main.py ## Licencia -Copyright © 2025 Álvaro Rodríguez. Todos los derechos reservados. +Copyright © 2025 Álvaro Romero. Todos los derechos reservados. Ver `LICENSE.txt` para detalles. diff --git a/arshipdesign/core/hull.py b/arshipdesign/core/hull.py index 7ddc152..ee65e15 100644 --- a/arshipdesign/core/hull.py +++ b/arshipdesign/core/hull.py @@ -48,6 +48,28 @@ class Hull: draft: float offsets: OffsetsTable _surface: Optional[LoftedSurface] = field(default=None, repr=False, compare=False) + # Altura del sheer (cubierta) por estación [m]. + # Permite líneas de cubierta con arrufo/caída sin alterar el puntal escalar. + # Default vacío → se usa hull.depth uniforme en todas las estaciones. + sheer_z: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False) + # Puntos de control de la roda (stem) en el plano X-Z — shape (n, 2). + # Default vacío → 3 puntos colineales de quilla-FP a cubierta-FP. + stem_ctrl: np.ndarray = field(default_factory=lambda: np.zeros((0, 2)), repr=False, compare=False) + # Puntos de control del contorno del espejo (AP) en el plano X-Z — shape (n, 2). + # Default vacío → 3 puntos colineales de quilla-AP a cubierta-AP. + transom_ctrl: np.ndarray = field(default_factory=lambda: np.zeros((0, 2)), repr=False, compare=False) + # Desviaciones X per-estación para los nodos de quilla y sheer [m]. + # X efectiva quilla(i) = offsets.x_stations[i] + keel_x_offsets[i]. + keel_x_offsets: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False) + sheer_x_offsets: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False) + # Posiciones X de los planos de estación para la visualización [m]. + # Independiente de la malla paramétrica (x_stations). + # Default vacío → se generan n=11 planos uniformes entre 0 y Lpp. + station_planes: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False) + # Nodos marcados como esquina: rompen la suavidad B-spline en ese punto. + # Cada elemento es [i, j] donde j puede ser _KEEL_IDX(-1), _SHEER_IDX(-2) + # o un índice de línea de agua (0 .. n_wl-1). + corner_nodes: list = field(default_factory=list, repr=False, compare=False) # ------------------------------------------------------------------ # Fábricas @@ -98,6 +120,149 @@ class Hull: self._surface = self._build_surface() return self._surface + def get_stem_ctrl(self) -> np.ndarray: + """Puntos de control de la roda en X-Z. + + El primer punto siempre coincide con la quilla en FP y el último + con el sheer en FP, garantizando continuidad del contorno del perfil. + Solo los puntos intermedios son libremente editables. + """ + x_fp = float(self.offsets.x_stations[-1]) + z0 = float(self.offsets.keel_z[-1]) # quilla en FP + z1 = float(self.get_sheer_z()[-1]) # sheer en FP + if self.stem_ctrl.shape[0] >= 3: + ctrl = self.stem_ctrl.copy() + ctrl[0] = [x_fp, z0] # snap inferior → quilla-FP + ctrl[-1] = [x_fp, z1] # snap superior → sheer-FP + return ctrl + # Default: roda vertical con 3 puntos + return np.array([[x_fp, z0], [x_fp, (z0 + z1) * 0.5], [x_fp, z1]]) + + def get_transom_ctrl(self) -> np.ndarray: + """Puntos de control del contorno del espejo (AP) en X-Z. + + Orden: [0] = quilla-AP, [-1] = sheer-AP. + El primer y último punto siempre están fijados a quilla/sheer en AP. + Solo los puntos intermedios son libremente editables. + """ + x_ap = float(self.offsets.x_stations[0]) + z0 = float(self.offsets.keel_z[0]) # quilla en AP + z1 = float(self.get_sheer_z()[0]) # sheer en AP + if self.transom_ctrl.shape[0] >= 3: + ctrl = self.transom_ctrl.copy() + ctrl[0] = [x_ap, z0] # snap inferior → quilla-AP + ctrl[-1] = [x_ap, z1] # snap superior → sheer-AP + return ctrl + return np.array([[x_ap, z0], [x_ap, (z0 + z1) * 0.5], [x_ap, z1]]) + + def get_sheer_z(self) -> np.ndarray: + """Alturas de cubierta (sheer) por estación [m]. + + Si ``sheer_z`` no está inicializado o tiene dimensión incorrecta, + devuelve un array uniforme con el valor del puntal (``depth``). + """ + n = self.offsets.n_stations + if len(self.sheer_z) == n: + return self.sheer_z + return np.full(n, self.depth) + + def get_keel_x_offsets(self) -> np.ndarray: + """Desviaciones X per-estación para los nodos de quilla [m].""" + n = self.offsets.n_stations + if len(self.keel_x_offsets) == n: + return self.keel_x_offsets + return np.zeros(n) + + def get_sheer_x_offsets(self) -> np.ndarray: + """Desviaciones X per-estación para los nodos de sheer [m].""" + n = self.offsets.n_stations + if len(self.sheer_x_offsets) == n: + return self.sheer_x_offsets + return np.zeros(n) + + def is_corner(self, i: int, j: int) -> bool: + """Indica si el nodo (i, j) está marcado como esquina.""" + return any(int(e[0]) == i and int(e[1]) == j for e in self.corner_nodes) + + def toggle_corner(self, i: int, j: int) -> None: + """Alterna el estado de esquina del nodo (i, j).""" + if self.is_corner(i, j): + self.corner_nodes = [e for e in self.corner_nodes + if not (int(e[0]) == i and int(e[1]) == j)] + else: + self.corner_nodes.append([i, j]) + + def get_station_planes(self, n: int = 11) -> np.ndarray: + """Posiciones X [m] de los planos de estación para visualización. + + Si no están configurados, devuelve n planos uniformes entre 0 y Lpp. + """ + if len(self.station_planes) >= 2: + return self.station_planes + return np.linspace(0.0, self.lpp, n) + + def insert_station(self, x: float) -> None: + """Inserta una nueva estación en la posición x [m], interpolando todos los arrays. + + Actualiza OffsetsTable (x_stations, data, keel_z) y Hull (sheer_z). + AP y FP no se pueden sobrepasar. + """ + ot = self.offsets + x = float(np.clip(x, float(ot.x_stations[0]) + 1e-3, float(ot.x_stations[-1]) - 1e-3)) + idx = int(np.searchsorted(ot.x_stations, x)) + + old_x = ot.x_stations.copy() + old_sheer = self.get_sheer_z().copy() + + # Interpolar semi-mangas y desviaciones para la nueva estación + new_y = np.array([ + float(np.interp(x, old_x, ot.data[:, j])) + for j in range(ot.n_waterlines) + ]) + new_keel_z = float(np.interp(x, old_x, ot.keel_z)) + new_sheer_z = float(np.interp(x, old_x, old_sheer)) + new_z_offsets = np.array([ + float(np.interp(x, old_x, ot.z_offsets[:, j])) + for j in range(ot.n_waterlines) + ]) + new_x_offsets = np.array([ + float(np.interp(x, old_x, ot.x_offsets[:, j])) + for j in range(ot.n_waterlines) + ]) + new_keel_x_off = float(np.interp(x, old_x, self.get_keel_x_offsets())) + new_sheer_x_off = float(np.interp(x, old_x, self.get_sheer_x_offsets())) + + ot.x_stations = np.insert(old_x, idx, x) + ot.data = np.insert(ot.data, idx, new_y, axis=0) + ot.keel_z = np.insert(ot.keel_z, idx, new_keel_z) + ot.z_offsets = np.insert(ot.z_offsets, idx, new_z_offsets, axis=0) + ot.x_offsets = np.insert(ot.x_offsets, idx, new_x_offsets, axis=0) + lbl = f"S{idx}" + ot.station_labels.insert(idx, lbl) + + self.sheer_z = np.insert(old_sheer, idx, new_sheer_z) + self.keel_x_offsets = np.insert(self.get_keel_x_offsets(), idx, new_keel_x_off) + self.sheer_x_offsets = np.insert(self.get_sheer_x_offsets(), idx, new_sheer_x_off) + self.invalidate() + + def insert_waterline(self, z: float) -> None: + """Inserta una nueva línea de agua en altura z [m], interpolando semi-mangas. + + No afecta keel_z ni sheer_z (son arrays por estación, no por LdA). + """ + ot = self.offsets + z = float(np.clip(z, float(ot.z_waterlines[0]) + 1e-3, float(ot.z_waterlines[-1]) - 1e-3)) + idx = int(np.searchsorted(ot.z_waterlines, z)) + new_y = np.array([ + float(np.interp(z, ot.z_waterlines, ot.data[i, :])) + for i in range(ot.n_stations) + ]) + ot.z_waterlines = np.insert(ot.z_waterlines, idx, z) + ot.data = np.insert(ot.data, idx, new_y, axis=1) + ot.z_offsets = np.insert(ot.z_offsets, idx, 0.0, axis=1) + ot.x_offsets = np.insert(ot.x_offsets, idx, 0.0, axis=1) + self.invalidate() + def invalidate(self) -> None: """Invalida la caché de la superficie NURBS. @@ -108,13 +273,81 @@ class Hull: """ self._surface = None + def snap_boundary_nodes_to_contours(self) -> None: + """Enclava los nodos extremos de cada línea de agua sobre las + aristas de terminación del casco: roda (FP, índice -1) y espejo + (AP, índice 0). + + El stem y el transom son **aristas de terminación** (boundary edges) + — no son curvas interiores de suavizado. Cada línea de agua debe + terminar exactamente donde intersecta esa arista. Este método + calcula la coordenada X de esa intersección para cada altura Z y + actualiza x_offsets[0,:] y x_offsets[-1,:] en consecuencia. + + Llamar tras: + - Cargar un casco desde disco (``from_dict``). + - Arrastrar un punto de control del stem o del transom. + """ + from arshipdesign.geometry.nurbs_curve import BSplineCurve + + ot = self.offsets + n_wl = ot.n_waterlines + NSAMP = max(300, n_wl * 30) + + def _sample_edge(ctrl: np.ndarray) -> np.ndarray: + """Muestrea la curva spline de una arista de terminación. + + Devuelve (NSAMP, 2) [X, Z] ordenado por Z creciente para + poder usar np.interp(z, ...) de forma segura. + """ + m = len(ctrl) + if m < 2: + return ctrl[np.argsort(ctrl[:, 1])] + try: + deg = min(3, m - 1) + curve = BSplineCurve(ctrl, degree=deg) + pts = curve.sample(NSAMP) # (NSAMP, 2): X, Z + except Exception: + t_raw = np.linspace(0.0, 1.0, m) + t_new = np.linspace(0.0, 1.0, NSAMP) + pts = np.column_stack([ + np.interp(t_new, t_raw, ctrl[:, 0]), + np.interp(t_new, t_raw, ctrl[:, 1]), + ]) + return pts[np.argsort(pts[:, 1])] # ordenar por Z + + stem_samp = _sample_edge(self.get_stem_ctrl()) # FP (i = -1) + trans_samp = _sample_edge(self.get_transom_ctrl()) # AP (i = 0) + + # Asegurar forma correcta + if ot.x_offsets.shape != (ot.n_stations, n_wl): + ot.x_offsets = np.zeros((ot.n_stations, n_wl)) + + z_stem_min, z_stem_max = float(stem_samp[:, 1].min()), float(stem_samp[:, 1].max()) + z_trans_min, z_trans_max = float(trans_samp[:, 1].min()), float(trans_samp[:, 1].max()) + + for j in range(n_wl): + z_fp = float(ot.z_waterlines[j]) + float(ot.z_offsets[-1, j]) + z_ap = float(ot.z_waterlines[j]) + float(ot.z_offsets[0, j]) + + # Clamp dentro del rango de la arista (evita extrapolación) + z_fp = float(np.clip(z_fp, z_stem_min, z_stem_max)) + z_ap = float(np.clip(z_ap, z_trans_min, z_trans_max)) + + x_on_stem = float(np.interp(z_fp, stem_samp[:, 1], stem_samp[:, 0])) + x_on_trans = float(np.interp(z_ap, trans_samp[:, 1], trans_samp[:, 0])) + + # x_offsets = X_efectiva − X_referencia_paramétrica + ot.x_offsets[-1, j] = x_on_stem - float(ot.x_stations[-1]) + ot.x_offsets[0, j] = x_on_trans - float(ot.x_stations[0]) + def _build_surface(self) -> LoftedSurface: sections_data = [] u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1] for i, u in enumerate(u_arr): pts = np.column_stack([ self.offsets.data[i, :], - self.offsets.z_waterlines, + self.offsets.z_waterlines + self.offsets.z_offsets[i, :], ]) sections_data.append((float(u), pts)) n_sec = len(sections_data) @@ -350,6 +583,8 @@ class Hull: def to_mesh(self, n_u: int = 40, n_v: int = 20) -> "pyvista.PolyData": """Genera una malla PyVista del casco (ambas bandas). + Cada sección se construye desde la quilla (keel_z per-estación) + hasta la cubierta (sheer_z per-estación), igual que BodyPlanViewer. Requiere PyVista instalado. Retorna un PolyData triangulado. """ try: @@ -357,34 +592,59 @@ class Hull: except ImportError as exc: raise ImportError("PyVista no está instalado") from exc - surf = self.surface - u_range = surf.u_range - u_arr = np.linspace(u_range[0], u_range[1], n_u) - v_arr = np.linspace(0.0, 1.0, n_v) - uu, vv = np.meshgrid(u_arr, v_arr, indexing="ij") # (n_u, n_v) + from arshipdesign.geometry.nurbs_curve import BSplineCurve - # Evaluar (y, z) en la malla - y_mat = surf._spline_y(u_arr, v_arr) # (n_u, n_v) - z_mat = surf._spline_z(u_arr, v_arr) # (n_u, n_v) + ot = self.offsets + sheer = self.get_sheer_z() - # x real desde parámetro u - x_mat = uu * self.lpp + # ── Paso 1: perfiles (y, z) a las estaciones originales ────────── + def _section_yz(i: int, n_pts: int) -> np.ndarray: + """Muestrea n_pts puntos del perfil (y, z) de la sección i.""" + kz = float(ot.keel_z[i]) + sz = float(sheer[i]) + y_arr = ot.data[i, :] + z_arr = ot.z_waterlines + ot.z_offsets[i, :] + keel_pt = np.array([[0.0, kz]]) + sheer_pt = np.array([[float(y_arr[-1]), sz]]) + raw = np.vstack([keel_pt, np.column_stack([y_arr, z_arr]), sheer_pt]) + m = len(raw) + try: + k = min(3, max(m - 1, 1)) + curve = BSplineCurve(raw, degree=k) + return curve.sample(n_pts) # (n_pts, 2) + except Exception: + # Fallback: linear re-sample of the raw polyline + t_raw = np.linspace(0, 1, m) + t_new = np.linspace(0, 1, n_pts) + return np.column_stack([ + np.interp(t_new, t_raw, raw[:, 0]), + np.interp(t_new, t_raw, raw[:, 1]), + ]) - # Banda de estribor (y > 0) - pts_stbd = np.stack([ - x_mat.ravel(), y_mat.ravel(), z_mat.ravel() - ], axis=1) + n_sta = ot.n_stations + profiles = np.array([_section_yz(i, n_v) for i in range(n_sta)]) + # profiles shape: (n_sta, n_v, 2) — (y, z) at each station - # Banda de babor (y < 0) - pts_port = np.stack([ - x_mat.ravel(), -y_mat.ravel(), z_mat.ravel() - ], axis=1) + # ── Paso 2: interpolar a n_u estaciones uniformes ──────────────── + x_orig = ot.x_stations / self.lpp # normalized [0,1] + x_new = np.linspace(0.0, 1.0, n_u) - # Unir ambas bandas - all_pts = np.vstack([pts_stbd, pts_port]) + yz_grid = np.zeros((n_u, n_v, 2)) + for v_idx in range(n_v): + for c in range(2): # y, z + yz_grid[:, v_idx, c] = np.interp(x_new, x_orig, profiles[:, v_idx, c]) - # Construir caras de la malla estructurada - faces = [] + # ── Paso 3: construir vértices 3D ───────────────────────────────── + x_grid = (x_new[:, None] * self.lpp) * np.ones((n_u, n_v)) + y_grid = yz_grid[:, :, 0] # semi-manga (estribor +) + z_grid = yz_grid[:, :, 1] + + pts_stbd = np.stack([x_grid.ravel(), y_grid.ravel(), z_grid.ravel()], axis=1) + pts_port = np.stack([x_grid.ravel(), -y_grid.ravel(), z_grid.ravel()], axis=1) + all_pts = np.vstack([pts_stbd, pts_port]) + + # ── Paso 4: caras de la malla estructurada ──────────────────────── + faces = [] offset = n_u * n_v for band in [0, offset]: for i in range(n_u - 1): @@ -395,8 +655,7 @@ class Hull: p3 = band + i * n_v + (j + 1) faces.extend([4, p0, p1, p2, p3]) - faces_arr = np.array(faces, dtype=int) - mesh = pv.PolyData(all_pts, faces_arr) + mesh = pv.PolyData(all_pts, np.array(faces, dtype=int)) return mesh.triangulate() # ------------------------------------------------------------------ @@ -415,12 +674,15 @@ class Hull: """ ot = self.offsets return { - "format": "hull_v1", - "name": self.name, - "lpp": self.lpp, - "beam": self.beam, - "depth": self.depth, - "draft": self.draft, + "format": "hull_v1", + "name": self.name, + "lpp": self.lpp, + "beam": self.beam, + "depth": self.depth, + "draft": self.draft, + "sheer_z": self.get_sheer_z().tolist(), + "stem_ctrl": self.get_stem_ctrl().tolist(), + "transom_ctrl": self.get_transom_ctrl().tolist(), "offsets": { "lpp": ot.lpp, "beam": ot.beam, @@ -428,8 +690,15 @@ class Hull: "x_stations": ot.x_stations.tolist(), "z_waterlines": ot.z_waterlines.tolist(), "station_labels": list(ot.station_labels), - "data": ot.data.tolist(), # (n_sta, n_wl) + "data": ot.data.tolist(), # (n_sta, n_wl) + "keel_z": ot.keel_z.tolist(), + "z_offsets": ot.z_offsets.tolist(), # (n_sta, n_wl) + "x_offsets": ot.x_offsets.tolist(), # (n_sta, n_wl) }, + "keel_x_offsets": self.get_keel_x_offsets().tolist(), + "sheer_x_offsets": self.get_sheer_x_offsets().tolist(), + "station_planes": self.get_station_planes().tolist(), + "corner_nodes": self.corner_nodes, } @classmethod @@ -459,15 +728,30 @@ class Hull: lpp = float(od["lpp"]), beam = float(od["beam"]), draft = float(od["draft"]), + keel_z = np.array(od.get("keel_z", []), dtype=float), + z_offsets = np.array(od.get("z_offsets", np.zeros((0, 0))), dtype=float), + x_offsets = np.array(od.get("x_offsets", np.zeros((0, 0))), dtype=float), ) - return cls( - name = str(data["name"]), - lpp = float(data["lpp"]), - beam = float(data["beam"]), - depth = float(data["depth"]), - draft = float(data["draft"]), + hull = cls( + name = str(data["name"]), + lpp = float(data["lpp"]), + beam = float(data["beam"]), + depth = float(data["depth"]), + draft = float(data["draft"]), offsets = offsets, + sheer_z = np.array(data.get("sheer_z", []), dtype=float), + stem_ctrl = np.array(data.get("stem_ctrl", []), dtype=float).reshape(-1, 2), + transom_ctrl = np.array(data.get("transom_ctrl", []), dtype=float).reshape(-1, 2), + keel_x_offsets = np.array(data.get("keel_x_offsets", []), dtype=float), + sheer_x_offsets = np.array(data.get("sheer_x_offsets", []), dtype=float), + station_planes = np.array(data.get("station_planes", []), dtype=float), + corner_nodes = list(data.get("corner_nodes", [])), ) + # NOTA: NO snap_boundary_nodes_to_contours() al cargar. + # Los x_offsets guardados son la posición que el usuario definió + # en el Visor Perfil (eje X libre) y se restauran tal cual. + # El snap solo aplica en la creación inicial del proyecto (wizard). + return hull # ------------------------------------------------------------------ # Dunder diff --git a/arshipdesign/core/offsets.py b/arshipdesign/core/offsets.py index 7c10b4f..26ab51a 100644 --- a/arshipdesign/core/offsets.py +++ b/arshipdesign/core/offsets.py @@ -47,11 +47,27 @@ class OffsetsTable: lpp: float = 0.0 beam: float = 0.0 draft: float = 0.0 + # Altura de la quilla por estación [m]. Permite quillas inclinadas + # (rise of keel / rocker) y quillas con perfil curvo sin alterar la + # cuadrícula de líneas de agua que permanece compartida. + # Default: cero en todas las estaciones (quilla plana sobre baseline). + keel_z: np.ndarray = field(default_factory=lambda: np.array([])) + # Desviación vertical per-nodo [m]. shape (n_sta, n_wl). + # La Z efectiva del nodo (i, j) = z_waterlines[j] + z_offsets[i, j]. + # Default: ceros → todos los nodos en los planos horizontales de referencia. + z_offsets: np.ndarray = field(default_factory=lambda: np.zeros((0, 0))) + # Desviación longitudinal per-nodo [m]. shape (n_sta, n_wl). + # La X efectiva del nodo (i, j) = x_stations[i] + x_offsets[i, j]. + # x_stations es la referencia paramétrica FIJA; nunca se modifica en drag. + x_offsets: np.ndarray = field(default_factory=lambda: np.zeros((0, 0))) def __post_init__(self) -> None: self.x_stations = np.asarray(self.x_stations, dtype=float) self.z_waterlines = np.asarray(self.z_waterlines, dtype=float) self.data = np.asarray(self.data, dtype=float) + self.keel_z = np.asarray(self.keel_z, dtype=float) + self.z_offsets = np.asarray(self.z_offsets, dtype=float) + self.x_offsets = np.asarray(self.x_offsets, dtype=float) n_sta = len(self.x_stations) n_wl = len(self.z_waterlines) @@ -61,6 +77,15 @@ class OffsetsTable: ) if not self.station_labels: self.station_labels = [str(i) for i in range(n_sta)] + # Inicializar keel_z si no se proporcionó o tiene dimensión incorrecta + if self.keel_z.shape != (n_sta,): + self.keel_z = np.zeros(n_sta) + # Inicializar z_offsets si no se proporcionó o tiene dimensiones incorrectas + if self.z_offsets.shape != (n_sta, n_wl): + self.z_offsets = np.zeros((n_sta, n_wl)) + # Inicializar x_offsets si no se proporcionó o tiene dimensiones incorrectas + if self.x_offsets.shape != (n_sta, n_wl): + self.x_offsets = np.zeros((n_sta, n_wl)) # ------------------------------------------------------------------ # Fábrica: casco Wigley analítico @@ -127,7 +152,7 @@ class OffsetsTable: station=sta, x=float(x), half_breadths=self.data[i, :].copy(), - z_positions=self.z_waterlines.copy(), + z_positions=(self.z_waterlines + self.z_offsets[i, :]).copy(), label=f"x={x:.3f} m", ) sections.append(sec) @@ -139,13 +164,14 @@ class OffsetsTable: def half_breadth(self, x: float, z: float) -> float: """Interpola la semi-manga en cualquier (x, z) [m].""" - # Interpolar en x + # Para cada estación: interpola la semi-manga a la altura z usando las + # z efectivas per-nodo (z_waterlines[j] + z_offsets[i, j]). col_y = np.array([ - float(np.interp(x, self.x_stations, self.data[:, j])) - for j in range(len(self.z_waterlines)) + float(np.interp(z, self.z_waterlines + self.z_offsets[i, :], self.data[i, :])) + for i in range(len(self.x_stations)) ]) - # Interpolar en z - return float(np.interp(z, self.z_waterlines, col_y)) + # Interpolar el resultado en x + return float(np.interp(x, self.x_stations, col_y)) @property def n_stations(self) -> int: diff --git a/arshipdesign/parametric/series60.py b/arshipdesign/parametric/series60.py index b2e6586..95dd855 100644 --- a/arshipdesign/parametric/series60.py +++ b/arshipdesign/parametric/series60.py @@ -22,6 +22,19 @@ from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +def _standard_sheer_z( + x_sta: np.ndarray, lpp: float, depth: float, + fwd_rise_frac: float = 0.04, aft_rise_frac: float = 0.020, +) -> np.ndarray: + """Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa.""" + xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship + return np.where( + xi >= 0.5, + depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2), + depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2), + ) + + def make_merchant_hull( name: str = "Buque Mercante / Supply", lpp: float = 20.0, @@ -49,9 +62,10 @@ def make_merchant_hull( flat_bottom_frac : float Ancho del fondo plano / manga (0.85–0.94). """ - x_sta = np.linspace(0.0, lpp, n_stations) - z_wl = np.linspace(0.0, draft, n_waterlines) - xi = (x_sta / lpp - 0.5) * 2.0 + x_sta = np.linspace(0.0, lpp, n_stations) + sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.04, aft_rise_frac=0.020) + z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines) + xi = (x_sta / lpp - 0.5) * 2.0 lcb_shift = 2.0 * (lcb_frac - 0.5) f_plan = _merchant_plan_form(xi, cb, lcb_shift) @@ -91,7 +105,8 @@ def make_merchant_hull( lpp=lpp, beam=beam, draft=draft, ) return Hull( - name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets + name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, + offsets=offsets, sheer_z=sheer_z, ) diff --git a/arshipdesign/parametric/wizard_cruiser.py b/arshipdesign/parametric/wizard_cruiser.py index bbcd4c1..621734d 100644 --- a/arshipdesign/parametric/wizard_cruiser.py +++ b/arshipdesign/parametric/wizard_cruiser.py @@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +def _standard_sheer_z( + x_sta: np.ndarray, lpp: float, depth: float, + fwd_rise_frac: float = 0.055, aft_rise_frac: float = 0.025, +) -> np.ndarray: + """Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa.""" + xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship + return np.where( + xi >= 0.5, + depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2), + depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2), + ) + + # --------------------------------------------------------------------------- # Forma de sección — carena redonda tipo desplazamiento # --------------------------------------------------------------------------- @@ -77,9 +90,10 @@ def make_displacement_hull( cm : float Coeficiente de cuaderna maestra (0.82–0.92). """ - x_sta = np.linspace(0.0, lpp, n_stations) - z_wl = np.linspace(0.0, draft, n_waterlines) - xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [−1, 1], 0=midship + x_sta = np.linspace(0.0, lpp, n_stations) + sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.055, aft_rise_frac=0.025) + z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines) + xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [−1, 1], 0=midship # ── Plan form (semi-manga en flotación) ──────────────────────────── # LCB desplazado del midship @@ -111,7 +125,8 @@ def make_displacement_hull( lpp=lpp, beam=beam, draft=draft, ) return Hull( - name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets + name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, + offsets=offsets, sheer_z=sheer_z, ) diff --git a/arshipdesign/parametric/wizard_planing.py b/arshipdesign/parametric/wizard_planing.py index f35c798..e0e685e 100644 --- a/arshipdesign/parametric/wizard_planing.py +++ b/arshipdesign/parametric/wizard_planing.py @@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +def _standard_sheer_z( + x_sta: np.ndarray, lpp: float, depth: float, + fwd_rise_frac: float = 0.03, aft_rise_frac: float = 0.015, +) -> np.ndarray: + """Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa.""" + xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship + return np.where( + xi >= 0.5, + depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2), + depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2), + ) + + # --------------------------------------------------------------------------- # API pública # --------------------------------------------------------------------------- @@ -56,9 +69,10 @@ def make_planing_hull( flare : float Fracción de ensanchamiento por encima del chine (0 = sin ensanche). """ - x_sta = np.linspace(0.0, lpp, n_stations) - z_wl = np.linspace(0.0, draft, n_waterlines) - xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [−1, 1], 0=midship + x_sta = np.linspace(0.0, lpp, n_stations) + sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.03, aft_rise_frac=0.015) + z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines) + xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [−1, 1], 0=midship # ── Plan form: ancho en línea de agua por estación ───────────────── # El planeador tiene popa muy ancha y proa más estrecha @@ -105,7 +119,8 @@ def make_planing_hull( lpp=lpp, beam=beam, draft=draft, ) return Hull( - name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets + name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, + offsets=offsets, sheer_z=sheer_z, ) diff --git a/arshipdesign/parametric/wizard_sailing_mono.py b/arshipdesign/parametric/wizard_sailing_mono.py index e8913a2..ca7c293 100644 --- a/arshipdesign/parametric/wizard_sailing_mono.py +++ b/arshipdesign/parametric/wizard_sailing_mono.py @@ -24,6 +24,19 @@ from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +def _standard_sheer_z( + x_sta: np.ndarray, lpp: float, depth: float, + fwd_rise_frac: float = 0.08, aft_rise_frac: float = 0.04, +) -> np.ndarray: + """Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa.""" + xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship + return np.where( + xi >= 0.5, + depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2), + depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2), + ) + + # --------------------------------------------------------------------------- # Forma de sección — velero (V-fondo + cuerpo redondeado) # --------------------------------------------------------------------------- @@ -45,6 +58,8 @@ def _sailing_section( """ if y_wl < 1e-9 or T < 1e-9: return 0.0 + if z >= T: + return y_wl # plumb topside above design waterline t_full = min(1.0, max(0.0, z / T)) if t_full < 1e-12: return 0.0 @@ -97,9 +112,10 @@ def make_sailing_hull( deadrise_mid : float Ángulo de astilla muerta en cuaderna maestra [°]. """ - x_sta = np.linspace(0.0, lpp, n_stations) - z_wl = np.linspace(0.0, draft, n_waterlines) - xi = (x_sta / lpp - 0.5) * 2.0 + x_sta = np.linspace(0.0, lpp, n_stations) + sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.08, aft_rise_frac=0.04) + z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines) + xi = (x_sta / lpp - 0.5) * 2.0 lcb_shift = 2.0 * (lcb_frac - 0.5) @@ -131,7 +147,8 @@ def make_sailing_hull( lpp=lpp, beam=beam, draft=draft, ) return Hull( - name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets + name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, + offsets=offsets, sheer_z=sheer_z, ) diff --git a/arshipdesign/parametric/wizard_workboat.py b/arshipdesign/parametric/wizard_workboat.py index 7fb9603..619f6ff 100644 --- a/arshipdesign/parametric/wizard_workboat.py +++ b/arshipdesign/parametric/wizard_workboat.py @@ -22,6 +22,21 @@ from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +def _standard_sheer_z( + x_sta: np.ndarray, lpp: float, depth: float, + fwd_rise_frac: float = 0.05, aft_rise_frac: float = 0.025, +) -> np.ndarray: + """Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa. + El puntal de trazado (depth) es el valor en cuaderna maestra. + """ + xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship + return np.where( + xi >= 0.5, + depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2), + depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2), + ) + + def make_workboat_hull( name: str = "Workboat / Supply", lpp: float = 15.0, @@ -44,9 +59,10 @@ def make_workboat_hull( flat_bottom_frac : float Anchura del fondo plano como fracción de la manga (0.80–0.92). """ - x_sta = np.linspace(0.0, lpp, n_stations) - z_wl = np.linspace(0.0, draft, n_waterlines) - xi = (x_sta / lpp - 0.5) * 2.0 + x_sta = np.linspace(0.0, lpp, n_stations) + sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.035, aft_rise_frac=0.020) + z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines) + xi = (x_sta / lpp - 0.5) * 2.0 lcb_shift = 2.0 * (lcb_frac - 0.5) @@ -91,7 +107,8 @@ def make_workboat_hull( lpp=lpp, beam=beam, draft=draft, ) return Hull( - name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets + name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, + offsets=offsets, sheer_z=sheer_z, ) diff --git a/arshipdesign/ui/icons.py b/arshipdesign/ui/icons.py new file mode 100644 index 0000000..b65de88 --- /dev/null +++ b/arshipdesign/ui/icons.py @@ -0,0 +1,1286 @@ +""" +icons.py — Iconos programáticos para el ribbon de AR-ShipDesign. + +Diseño: "flat icon" con relleno sólido de color + contorno oscuro. +Visible tanto en fondos claros (ribbon blanco) como en fondos oscuros. + +Paleta por categoría: + Geometría nueva : azul océano #2a7fc8 + Edición NURBS : índigo #5548d0 + Suavizado/Fairness: verde #20a860, púrpura #7040c8, gradiente rojo-verde + Análisis hidro : teal #1898a8 + Estabilidad : azul #2068c0 + Resistencia : naranja #d07020 + Tanques : cyan #18a0c0 + Sistemas : amarillo #d0b818 (eléctrico), cyan (fluidos) + Fabricación : violeta #8838b8 + +Autor: Álvaro Romero — AR-ShipDesign Sprint 1 +""" +from __future__ import annotations + +import math +from typing import Callable + +from PySide6.QtCore import Qt, QPointF, QRectF +from PySide6.QtGui import ( + QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap, + QBrush, QFont, QLinearGradient, QRadialGradient, +) + +# ─── Constantes de dibujo ──────────────────────────────────────────────────── +_SIZE = 24 +_OUT = QColor("#1a2535") # contorno oscuro — visible sobre fondo blanco + +def _canvas(): + px = QPixmap(_SIZE, _SIZE) + px.fill(Qt.GlobalColor.transparent) + p = QPainter(px) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + return px, p + +def _pen(color=_OUT, w=1.6): + pen = QPen(color, w) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + return pen + +def _fill(p: QPainter, path: QPainterPath, + fill: QColor, stroke: QColor = _OUT, sw: float = 1.6): + p.setPen(_pen(stroke, sw)) + p.setBrush(QBrush(fill)) + p.drawPath(path) + p.setBrush(Qt.BrushStyle.NoBrush) + +def _finish(p, px): + p.end() + return QIcon(px) + +def _rect_path(x, y, w, h, rx=0): + pa = QPainterPath() + pa.addRoundedRect(QRectF(x, y, w, h), rx, rx) + return pa + +# ─── Colores por categoría ─────────────────────────────────────────────────── +_C_HULL = QColor("#2a7fc8") # geometría / casco +_C_NURBS = QColor("#5548d0") # edición NURBS +_C_SMOOTH = QColor("#20a860") # suavizado verde +_C_COMB = QColor("#7040c8") # peines púrpura +_C_FAIR_G = QColor("#10c870") # fairness verde +_C_FAIR_R = QColor("#e03828") # fairness rojo +_C_HYDRO = QColor("#1898a8") # hidrostática teal +_C_GZ = QColor("#2068c0") # estabilidad azul +_C_RES = QColor("#d07020") # resistencia naranja +_C_TANK = QColor("#18a0c0") # tanques cyan +_C_ELEC = QColor("#d0b818") # eléctrico amarillo +_C_WATER = QColor("#3898d8") # agua cyan-azul +_C_FIRE = QColor("#d83020") # incendio rojo +_C_FUEL = QColor("#e07818") # combustible naranja +_C_FAB = QColor("#8838b8") # fabricación violeta +_C_STRUCT = QColor("#6888a8") # estructura gris-azul +_C_WHITE = QColor("#f0f4ff") # blanco cálido (relleno claro) +_C_MINT = QColor("#00e8a0") # mint verde (acento) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# HOME — Vistas +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_lines_plan(): + """Plano de líneas — secciones anidadas de casco.""" + px, p = _canvas() + # Fondo de panel + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#ddeeff"), _OUT, 1.4) + # 3 secciones en U anidadas (cuadernas) + for oy, w, col in [(3, 9, _C_HULL), (7, 7, QColor("#4a9fd8")), (11, 5, QColor("#6ab8e8"))]: + path = QPainterPath() + path.moveTo(12 - w, oy + 9) + path.cubicTo(12 - w, oy, 12 + w, oy, 12 + w, oy + 9) + p.setPen(_pen(col, 2.0)) + p.drawPath(path) + # Línea de agua + p.setPen(_pen(_C_WATER, 1.6)) + p.drawLine(QPointF(2, 20), QPointF(22, 20)) + return _finish(p, px) + + +def _ico_4views(): + """4 Vistas — cuatro paneles de viewport.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 2) + _fill(p, bg, QColor("#e8eef8"), _OUT, 1.2) + # 3 paneles grises + 1 cyan (perspectiva 3D) + for (x, y, w, h, col) in [ + (2, 2, 9, 9, QColor("#c8d8e8")), + (13, 2, 9, 9, QColor("#c8d8e8")), + (2, 13, 9, 9, QColor("#c8d8e8")), + (13, 13, 9, 9, _C_HULL), + ]: + r = _rect_path(x, y, w, h, 1) + _fill(p, r, col, _OUT, 1.0) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GEOMETRÍA — Nuevo +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_wizard(): + """Asistente — varita mágica con estrella de destello.""" + px, p = _canvas() + # Varita (fondo oscuro → visible) + p.setPen(_pen(_OUT, 2.4)) + p.drawLine(QPointF(4, 20), QPointF(17, 7)) + # Empuñadura + p.setPen(_pen(QColor("#8060a0"), 4.0)) + p.drawLine(QPointF(4, 20), QPointF(9, 15)) + # Estrella de 4 puntas + p.setPen(_pen(_C_ELEC, 2.0)) + cx, cy = 18.5, 5.5 + for a in [0, 90, 180, 270]: + rad = math.radians(a) + p.drawLine(QPointF(cx + 3*math.cos(rad), cy + 3*math.sin(rad)), + QPointF(cx, cy)) + for a in [45, 135, 225, 315]: + rad = math.radians(a) + p.drawLine(QPointF(cx + 1.8*math.cos(rad), cy + 1.8*math.sin(rad)), + QPointF(cx, cy)) + # Destellos pequeños + p.setPen(_pen(_C_ELEC, 1.2)) + for (x1,y1,x2,y2) in [(5,11,6,10),(8,14,10,13),(6,14,6,12)]: + p.drawLine(QPointF(x1,y1), QPointF(x2,y2)) + return _finish(p, px) + + +def _ico_hull_nurbs(): + """Casco NURBS — sección maestra rellena.""" + px, p = _canvas() + # Media cuaderna rellena + path = QPainterPath() + path.moveTo(12, 3) + path.cubicTo(19, 3, 22, 13, 20, 20) + path.lineTo(4, 20) + path.cubicTo(2, 13, 5, 3, 12, 3) + _fill(p, path, _C_HULL, _OUT, 1.6) + # Línea de crujía + p.setPen(QPen(_C_WHITE, 1.2, Qt.PenStyle.DashLine)) + p.drawLine(QPointF(12, 3), QPointF(12, 20)) + # Línea de base + p.setPen(_pen(_C_WATER, 2.0)) + p.drawLine(QPointF(2, 20), QPointF(22, 20)) + return _finish(p, px) + + +def _ico_appendage(): + """Apéndice — casco + quilla/aleta.""" + px, p = _canvas() + # Casco (banda horizontal) + hull = _rect_path(2, 8, 20, 5, 2) + _fill(p, hull, _C_HULL, _OUT, 1.4) + # Aleta (fin) — más oscura + fin = QPainterPath() + fin.moveTo(9, 13) + fin.lineTo(7, 22) + fin.lineTo(13, 22) + fin.lineTo(15, 13) + _fill(p, fin, QColor("#1a5a9a"), _OUT, 1.4) + # Nodos de control en amarillo + p.setPen(_pen(_C_ELEC, 1.0)) + p.setBrush(QBrush(_C_ELEC)) + for pt in [(9,13),(7,22),(13,22),(15,13)]: + p.drawEllipse(QRectF(pt[0]-2, pt[1]-2, 4, 4)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GEOMETRÍA — Edición NURBS +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_ctrl_pts(): + """Puntos de Control — polígono de control visible.""" + px, p = _canvas() + pts = [(3, 20), (5, 9), (12, 4), (19, 9), (21, 18)] + # Polígono de control gris + p.setPen(QPen(QColor("#a0b0c8"), 1.4, Qt.PenStyle.DashLine)) + for i in range(len(pts)-1): + p.drawLine(QPointF(*pts[i]), QPointF(*pts[i+1])) + # Curva NURBS en índigo + p.setPen(_pen(_C_NURBS, 2.2)) + path = QPainterPath() + path.moveTo(*pts[0]) + path.cubicTo(5,11,9,3,12,4) + path.cubicTo(15,5,20,11,21,18) + p.drawPath(path) + # Nodos cuadrados amarillos + p.setPen(_pen(_OUT, 1.2)) + p.setBrush(QBrush(_C_ELEC)) + for pt in pts: + p.drawRect(QRectF(pt[0]-2.5, pt[1]-2.5, 5, 5)) + return _finish(p, px) + + +def _ico_extrude(): + """Extruir — sección 2D con flecha 3D.""" + px, p = _canvas() + # Sección frontal rellena (índigo) + face = _rect_path(2, 9, 10, 12, 1) + _fill(p, face, _C_NURBS, _OUT, 1.4) + # Profundidad / perspectiva (naranja) + p.setPen(_pen(_C_RES, 1.8)) + for (sx,sy,ex,ey) in [(12,9,19,4),(12,15,19,10),(12,21,19,16)]: + p.drawLine(QPointF(sx,sy), QPointF(ex,ey)) + p.drawLine(QPointF(19,4), QPointF(19,16)) + # Punta de flecha + p.setPen(_pen(_C_RES, 2.0)) + p.drawLine(QPointF(16,2), QPointF(19,4)) + p.drawLine(QPointF(21,3), QPointF(19,4)) + return _finish(p, px) + + +def _ico_mirror(): + """Espejo — forma asimétrica + reflejo.""" + px, p = _canvas() + # Línea de crujía (cian punteado) + p.setPen(QPen(_C_WATER, 1.6, Qt.PenStyle.DashLine)) + p.drawLine(QPointF(12, 2), QPointF(12, 22)) + # Forma original (sólida, azul) + path1 = QPainterPath() + path1.moveTo(12, 4) + path1.cubicTo(4, 5, 2, 12, 4, 20) + path1.lineTo(12, 20) + _fill(p, path1, _C_HULL, _OUT, 1.6) + # Reflejo (semi-transparente, más claro) + path2 = QPainterPath() + path2.moveTo(12, 4) + path2.cubicTo(20, 5, 22, 12, 20, 20) + path2.lineTo(12, 20) + _fill(p, path2, QColor(42, 127, 200, 80), QColor("#4090c0"), 1.2) + return _finish(p, px) + + +def _ico_lackenby(): + """Lackenby — curva que se deforma longitudinalmente.""" + px, p = _canvas() + # Curva original (gris) + p.setPen(_pen(QColor("#a0b0c8"), 1.8)) + path1 = QPainterPath() + path1.moveTo(2, 20) + path1.cubicTo(6, 4, 18, 4, 22, 20) + p.drawPath(path1) + # Curva transformada (naranja vivo) + p.setPen(_pen(_C_RES, 2.4)) + path2 = QPainterPath() + path2.moveTo(2, 20) + path2.cubicTo(4, 8, 13, 3, 22, 20) + p.drawPath(path2) + # Flecha de transformación (amarillo) + p.setPen(_pen(_C_ELEC, 2.0)) + p.drawLine(QPointF(8, 12), QPointF(14, 8)) + p.drawLine(QPointF(14, 8), QPointF(12, 11)) + p.drawLine(QPointF(14, 8), QPointF(11, 6)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GEOMETRÍA — Importar +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_import_offsets(): + """Importar Offsets — tabla + flecha verde de entrada.""" + px, p = _canvas() + tbl = _rect_path(6, 5, 15, 14, 1) + _fill(p, tbl, _C_WHITE, _OUT, 1.3) + for y in [9, 13]: + p.setPen(_pen(_OUT, 0.9)) + p.drawLine(QPointF(6, y), QPointF(21, y)) + p.drawLine(QPointF(12, 5), QPointF(12, 19)) + # Flecha verde entrante + p.setPen(_pen(_C_SMOOTH, 2.2)) + p.drawLine(QPointF(1, 12), QPointF(6, 12)) + p.drawLine(QPointF(4, 10), QPointF(6, 12)) + p.drawLine(QPointF(4, 14), QPointF(6, 12)) + return _finish(p, px) + + +def _ico_import_dxf(): + """Importar DXF — carpeta naranja con letras.""" + px, p = _canvas() + folder = QPainterPath() + folder.moveTo(2, 18) + folder.lineTo(2, 11) + folder.lineTo(7, 11) + folder.lineTo(9, 8) + folder.lineTo(21, 8) + folder.lineTo(21, 18) + folder.closeSubpath() + _fill(p, folder, _C_RES, _OUT, 1.4) + f = QFont("Arial", 5, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_WHITE, 1.0)) + p.drawText(QRectF(5, 11, 14, 8), Qt.AlignmentFlag.AlignCenter, "DXF") + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GEOMETRÍA — Exportar +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_export_iges(): + """Exportar IGES — disco verde claro.""" + px, p = _canvas() + disk = _rect_path(2, 2, 20, 20, 3) + _fill(p, disk, QColor("#20b880"), _OUT, 1.4) + slot = _rect_path(6, 2, 8, 7, 1) + _fill(p, slot, QColor("#e8f8f0"), _OUT, 1.0) + label = _rect_path(3, 13, 18, 8, 2) + _fill(p, label, QColor("#10905c"), _OUT, 1.0) + f = QFont("Arial", 5, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_WHITE, 1.0)) + p.drawText(QRectF(3, 13, 18, 8), Qt.AlignmentFlag.AlignCenter, "IGES") + return _finish(p, px) + + +def _ico_export_step(): + """Exportar STEP — disco teal.""" + px, p = _canvas() + disk = _rect_path(2, 2, 20, 20, 3) + _fill(p, disk, _C_HYDRO, _OUT, 1.4) + slot = _rect_path(6, 2, 8, 7, 1) + _fill(p, slot, QColor("#e0f4f8"), _OUT, 1.0) + label = _rect_path(3, 13, 18, 8, 2) + _fill(p, label, QColor("#107888"), _OUT, 1.0) + f = QFont("Arial", 5, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_WHITE, 1.0)) + p.drawText(QRectF(3, 13, 18, 8), Qt.AlignmentFlag.AlignCenter, "STEP") + return _finish(p, px) + + +def _ico_export_dxf(): + """Exportar DXF — disco naranja.""" + px, p = _canvas() + disk = _rect_path(2, 2, 20, 20, 3) + _fill(p, disk, _C_RES, _OUT, 1.4) + slot = _rect_path(6, 2, 8, 7, 1) + _fill(p, slot, QColor("#fce8d0"), _OUT, 1.0) + label = _rect_path(3, 13, 18, 8, 2) + _fill(p, label, QColor("#a05010"), _OUT, 1.0) + f = QFont("Arial", 5, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_WHITE, 1.0)) + p.drawText(QRectF(3, 13, 18, 8), Qt.AlignmentFlag.AlignCenter, "DXF") + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GEOMETRÍA — Suavizado +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_smooth(): + """Suavizar — zigzag rojo → curva verde suave.""" + px, p = _canvas() + # Zigzag rugoso (rojo) + p.setPen(_pen(_C_FAIR_R, 2.0)) + pts = [(2,15),(5,8),(7,16),(9,9),(11,14)] + for i in range(len(pts)-1): + p.drawLine(QPointF(*pts[i]), QPointF(*pts[i+1])) + # Flecha de transformación + p.setPen(_pen(_OUT, 1.8)) + p.drawLine(QPointF(11, 11), QPointF(14, 11)) + p.drawLine(QPointF(12, 9), QPointF(14, 11)) + p.drawLine(QPointF(12, 13), QPointF(14, 11)) + # Curva suave (verde vivo) + p.setPen(_pen(_C_SMOOTH, 2.6)) + path = QPainterPath() + path.moveTo(14, 15) + path.cubicTo(16, 7, 19, 7, 22, 13) + p.drawPath(path) + return _finish(p, px) + + +def _ico_combs(): + """Peines de curvatura — curva púrpura + pelos verdes densos.""" + px, p = _canvas() + # Spine (curva base) — negra/oscura, bien visible + p.setPen(_pen(_OUT, 2.0)) + path = QPainterPath() + path.moveTo(2, 17) + path.cubicTo(5, 8, 19, 8, 22, 17) + p.drawPath(path) + # Dibujamos la curva en púrpura encima + p.setPen(_pen(_C_COMB, 2.4)) + p.drawPath(path) + # Pelos verdes densos (8 hairs) + p.setPen(_pen(_C_SMOOTH, 1.6)) + hairs = [ + (3.0, 16.5, 3.0, 11.0), + (5.5, 12.5, 5.5, 7.0), + (8.0, 10.0, 8.0, 4.0), + (11.0, 9.0, 11.0, 3.0), + (14.0, 9.0, 14.0, 3.0), + (17.0, 10.5, 17.0, 4.5), + (19.5, 12.5, 19.5, 7.0), + (21.5, 16.0, 21.5, 10.5), + ] + for x1,y1,x2,y2 in hairs: + p.drawLine(QPointF(x1,y1), QPointF(x2,y2)) + # Spine de las puntas (línea de curvatura) + p.setPen(QPen(_C_MINT, 1.4, Qt.PenStyle.DashLine)) + tips = QPainterPath() + tips.moveTo(3.0, 11.0) + tips.cubicTo(5.0, 5.0, 11.0, 2.5, 12.5, 2.5) + tips.cubicTo(15.0, 2.5, 20.0, 5.5, 21.5, 10.5) + p.drawPath(tips) + return _finish(p, px) + + +def _ico_fairness(): + """Equidad — barra de gradiente verde→rojo sobre curva.""" + px, p = _canvas() + # Gradiente de fondo + grad = QLinearGradient(2, 12, 22, 12) + grad.setColorAt(0.0, _C_FAIR_G) + grad.setColorAt(0.5, QColor("#c0d020")) + grad.setColorAt(1.0, _C_FAIR_R) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(grad)) + p.drawRoundedRect(QRectF(2, 19, 20, 4), 2, 2) + p.setBrush(Qt.BrushStyle.NoBrush) + # Curva por encima (negra, contrastada) + p.setPen(_pen(_OUT, 2.2)) + curve = QPainterPath() + curve.moveTo(2, 17) + curve.cubicTo(6, 6, 18, 6, 22, 17) + p.drawPath(curve) + # Puntitos sobre la curva (valores de equidad) + for x, y, col in [(5,10,_C_FAIR_G),(10,7,_C_FAIR_G),(14,7,_C_ELEC),(18,10,_C_FAIR_R)]: + p.setPen(_pen(_OUT, 1.0)) + p.setBrush(QBrush(col)) + p.drawEllipse(QRectF(x-2,y-2,4,4)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ANÁLISIS — Hidrostática +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_hydro_calc(): + """Calcular hidrostáticos — Σ sobre fondo teal.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 4) + _fill(p, bg, _C_HYDRO, _OUT, 1.4) + f = QFont("serif", 16, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_WHITE, 1.0)) + p.drawText(QRectF(1, 0, 22, 24), Qt.AlignmentFlag.AlignCenter, "Σ") + return _finish(p, px) + + +def _ico_hydro_curves(): + """Curvas hidrostáticas — gráfico con 3 curvas de colores.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#e8f6f8"), _OUT, 1.2) + # Ejes + p.setPen(_pen(QColor("#4088a0"), 1.4)) + p.drawLine(QPointF(3, 21), QPointF(3, 3)) + p.drawLine(QPointF(3, 21), QPointF(22, 21)) + # 3 curvas + for pts, col in [ + ([(3,20),(7,13),(12,10),(17,8),(22,7)], _C_HYDRO), + ([(3,19),(7,16),(12,14),(17,12),(22,11)], _C_SMOOTH), + ([(3,21),(7,18),(12,16),(17,15),(22,14)], _C_RES), + ]: + p.setPen(_pen(col, 1.8)) + path = QPainterPath() + path.moveTo(*pts[0]) + for pt in pts[1:]: + path.lineTo(*pt) + p.drawPath(path) + return _finish(p, px) + + +def _ico_export_csv(): + """Exportar CSV — tabla con flecha de exportación.""" + px, p = _canvas() + tbl = _rect_path(2, 4, 14, 16, 1) + _fill(p, tbl, _C_WHITE, _OUT, 1.3) + p.setPen(_pen(_OUT, 0.9)) + for y in [8, 12, 16]: + p.drawLine(QPointF(2, y), QPointF(16, y)) + for x in [7, 12]: + p.drawLine(QPointF(x, 4), QPointF(x, 20)) + # Flecha verde saliente + p.setPen(_pen(_C_SMOOTH, 2.4)) + p.drawLine(QPointF(16, 12), QPointF(22, 12)) + p.drawLine(QPointF(19.5, 9.5), QPointF(22, 12)) + p.drawLine(QPointF(19.5, 14.5), QPointF(22, 12)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ANÁLISIS — Estabilidad +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_gz_curve(): + """Curva GZ — gráfico con curva verde + etiquetas.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#e8f0f8"), _OUT, 1.2) + # Ejes + p.setPen(_pen(_OUT, 1.4)) + p.drawLine(QPointF(3, 20), QPointF(21, 20)) + p.drawLine(QPointF(3, 20), QPointF(3, 3)) + # Curva GZ (verde vivo) + p.setPen(_pen(_C_SMOOTH, 2.4)) + gz = QPainterPath() + gz.moveTo(3, 20) + gz.cubicTo(7, 8, 13, 5, 15, 10) + gz.cubicTo(17, 15, 20, 20, 21, 20) + p.drawPath(gz) + # Etiquetas G, Z + f = QFont("Arial", 5, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_RES, 1.0)) + p.drawText(QRectF(4, 14, 5, 6), Qt.AlignmentFlag.AlignCenter, "G") + p.setPen(_pen(_C_GZ, 1.0)) + p.drawText(QRectF(10, 8, 5, 5), Qt.AlignmentFlag.AlignCenter, "Z") + return _finish(p, px) + + +def _ico_imo(): + """IMO IS2008 — libro azul abierto con sello.""" + px, p = _canvas() + # Tapa izquierda + left = QPainterPath() + left.moveTo(3, 5); left.quadTo(7, 4, 12, 6) + left.lineTo(12, 21); left.quadTo(7, 19, 3, 20) + left.closeSubpath() + _fill(p, left, _C_GZ, _OUT, 1.4) + # Tapa derecha + right = QPainterPath() + right.moveTo(21, 5); right.quadTo(17, 4, 12, 6) + right.lineTo(12, 21); right.quadTo(17, 19, 21, 20) + right.closeSubpath() + _fill(p, right, QColor("#3880d8"), _OUT, 1.4) + # Lomo + p.setPen(_pen(_OUT, 1.8)) + p.drawLine(QPointF(12, 4), QPointF(12, 21)) + # Texto IMO + f = QFont("Arial", 4, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_ELEC, 1.0)) + p.drawText(QRectF(13, 10, 8, 6), Qt.AlignmentFlag.AlignCenter, "IMO") + return _finish(p, px) + + +def _ico_damage(): + """Avería — buque con brecha roja + agua.""" + px, p = _canvas() + # Casco + hull = QPainterPath() + hull.moveTo(2, 9); hull.lineTo(2, 18) + hull.lineTo(22, 18); hull.lineTo(22, 9) + hull.quadTo(12, 15, 2, 9) + _fill(p, hull, _C_HULL, _OUT, 1.4) + # Superestructura + deck = _rect_path(7, 5, 9, 4, 1) + _fill(p, deck, QColor("#3a70a8"), _OUT, 1.2) + # Brecha / damage en rojo + p.setPen(_pen(_C_FIRE, 2.4)) + p.drawLine(QPointF(11, 16), QPointF(14, 19)) + p.drawLine(QPointF(14, 16), QPointF(11, 19)) + # Olas de agua + p.setPen(_pen(_C_WATER, 1.6)) + for dx in [-3, 0, 3]: + wp = QPainterPath() + wp.moveTo(12+dx, 20) + wp.quadTo(13+dx, 18, 12+dx, 17) + p.drawPath(wp) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ANÁLISIS — Resistencia +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_holtrop(): + """Holtrop & Mennen — buque naranja con olas.""" + px, p = _canvas() + # Casco naranja + hull = QPainterPath() + hull.moveTo(2, 14) + hull.cubicTo(4, 10, 8, 9, 14, 9) + hull.lineTo(20, 9); hull.cubicTo(22, 9, 22, 14, 20, 14) + hull.closeSubpath() + _fill(p, hull, _C_RES, _OUT, 1.4) + # Superestructura + deck = _rect_path(7, 5, 8, 4, 1) + _fill(p, deck, QColor("#c08820"), _OUT, 1.2) + # Olas cian + p.setPen(_pen(_C_WATER, 1.8)) + for ox in [0, 4, 8]: + wp = QPainterPath() + wp.moveTo(2+ox, 18) + wp.cubicTo(3+ox, 15, 5+ox, 15, 6+ox, 18) + p.drawPath(wp) + return _finish(p, px) + + +def _ico_savitsky(): + """Savitsky — planeador a alta velocidad.""" + px, p = _canvas() + # Casco inclinado (naranja) + hull = QPainterPath() + hull.moveTo(2, 21); hull.lineTo(21, 9) + hull.lineTo(22, 11); hull.lineTo(3, 23) + hull.closeSubpath() + _fill(p, hull, _C_RES, _OUT, 1.4) + # Estela / spray (azul) + p.setPen(_pen(_C_WATER, 1.4)) + for i in range(5): + p.drawLine(QPointF(3+i*2, 22-i*0.5), + QPointF(3+i*2-3, 23+i*0.3)) + # "V" de velocidad + f = QFont("Arial", 6, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_C_ELEC, 1.0)) + p.drawText(QRectF(13, 2, 9, 8), Qt.AlignmentFlag.AlignCenter, "V") + return _finish(p, px) + + +def _ico_vpp(): + """VPP Velero — vela mayor + foque.""" + px, p = _canvas() + # Mástil + p.setPen(_pen(_OUT, 2.0)) + p.drawLine(QPointF(12, 22), QPointF(12, 2)) + # Vela mayor (azul) + main = QPainterPath() + main.moveTo(12, 3); main.quadTo(20, 10, 12, 22) + main.closeSubpath() + _fill(p, main, _C_GZ, _OUT, 1.4) + # Foque (teal) + jib = QPainterPath() + jib.moveTo(12, 5); jib.quadTo(4, 12, 12, 20) + jib.closeSubpath() + _fill(p, jib, _C_HYDRO, _OUT, 1.2) + # Flotación + p.setPen(_pen(_C_WATER, 1.6)) + p.drawLine(QPointF(4, 22), QPointF(20, 22)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ANÁLISIS — Seakeeping +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_stf(): + """Strip Theory (STF) — buque con franjas transversales.""" + px, p = _canvas() + # Casco + hull = QPainterPath() + hull.moveTo(2, 14); hull.cubicTo(4, 10, 8, 9, 14, 9) + hull.lineTo(21, 9); hull.lineTo(21, 14); hull.closeSubpath() + _fill(p, hull, _C_GZ, _OUT, 1.4) + # Franjas (strips) en cyan + p.setPen(_pen(_C_MINT, 1.6)) + for x in [6, 10, 14, 18]: + p.drawLine(QPointF(x, 9), QPointF(x, 14)) + # Olas base + p.setPen(_pen(_C_WATER, 1.4)) + wave = QPainterPath() + wave.moveTo(2, 18) + for i in range(5): + wave.cubicTo(3+i*4, 15, 5+i*4, 21, 6+i*4, 18) + p.drawPath(wave) + return _finish(p, px) + + +def _ico_spectrum(): + """Espectro de respuesta — campana espectral sobre ejes.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#e8f0f8"), _OUT, 1.2) + p.setPen(_pen(_OUT, 1.2)) + p.drawLine(QPointF(3, 21), QPointF(21, 21)) + p.drawLine(QPointF(3, 21), QPointF(3, 3)) + # Campana rellena (teal) + bell = QPainterPath() + bell.moveTo(3, 21) + bell.cubicTo(5, 21, 8, 4, 11, 4) + bell.cubicTo(14, 4, 17, 21, 21, 21) + bell.closeSubpath() + _fill(p, bell, QColor(24, 152, 168, 100), _C_HYDRO, 1.6) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ANÁLISIS — Estructura +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_iso12215(): + """ISO 12215 — perfil T de acero estructural.""" + px, p = _canvas() + # Ala del perfil T (flange) + flange = _rect_path(3, 7, 18, 4, 1) + _fill(p, flange, _C_STRUCT, _OUT, 1.6) + # Alma (web) + web = _rect_path(9, 11, 6, 11, 1) + _fill(p, web, _C_STRUCT, _OUT, 1.6) + # Línea de cotas amarilla + p.setPen(_pen(_C_ELEC, 1.2)) + p.drawLine(QPointF(3, 4), QPointF(21, 4)) + p.drawLine(QPointF(3, 3), QPointF(3, 5)) + p.drawLine(QPointF(21, 3), QPointF(21, 5)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# TANQUES +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_new_tank(): + """Nuevo Tanque — cilindro cyan con nivel de líquido.""" + px, p = _canvas() + # Cuerpo del cilindro + body = _rect_path(5, 7, 14, 12, 1) + _fill(p, body, QColor("#d0f0f8"), _OUT, 1.4) + # Nivel de líquido (cyan relleno) + liquid = _rect_path(5, 13, 14, 6, 1) + _fill(p, liquid, _C_TANK, _OUT, 0.8) + # Tapas elípticas + p.setPen(_pen(_OUT, 1.4)) + p.setBrush(QBrush(QColor("#e8f8fc"))) + p.drawEllipse(QRectF(5, 4, 14, 6)) + p.setBrush(QBrush(_C_TANK)) + p.drawEllipse(QRectF(5, 16, 14, 6)) + # Línea de nivel + p.setPen(_pen(_C_WHITE, 1.6)) + p.drawLine(QPointF(5, 13), QPointF(19, 13)) + return _finish(p, px) + + +def _ico_model_tank(): + """Modelar Tanque — caja 3D isométrica.""" + px, p = _canvas() + # Cara frontal + front = _rect_path(2, 11, 13, 11, 1) + _fill(p, front, _C_TANK, _OUT, 1.4) + # Techo (perspectiva) + roof = QPainterPath() + roof.moveTo(2, 11); roof.lineTo(7, 5) + roof.lineTo(20, 5); roof.lineTo(15, 11) + roof.closeSubpath() + _fill(p, roof, QColor("#38b8d8"), _OUT, 1.4) + # Lado derecho + side = QPainterPath() + side.moveTo(15, 11); side.lineTo(20, 5) + side.lineTo(20, 16); side.lineTo(15, 22) + side.closeSubpath() + _fill(p, side, QColor("#1080a0"), _OUT, 1.4) + # + verde (nuevo) + p.setPen(_pen(_C_SMOOTH, 2.2)) + p.drawLine(QPointF(6,16), QPointF(10,16)) + p.drawLine(QPointF(8,14), QPointF(8,18)) + return _finish(p, px) + + +def _ico_load_case(): + """Caso de Carga — balanza de platillos.""" + px, p = _canvas() + # Barra horizontal + p.setPen(_pen(_OUT, 2.0)) + p.drawLine(QPointF(4, 10), QPointF(20, 10)) + # Mástil vertical + p.drawLine(QPointF(12, 10), QPointF(12, 21)) + p.drawLine(QPointF(9, 21), QPointF(15, 21)) + # Platillo izquierdo + lp = QPainterPath() + lp.moveTo(3, 10); lp.quadTo(5, 17, 8, 17); lp.quadTo(11, 17, 9, 10) + _fill(p, lp, QColor("#d0d8e8"), _OUT, 1.2) + # Platillo derecho + rp = QPainterPath() + rp.moveTo(21, 10); rp.quadTo(19, 17, 16, 17); rp.quadTo(13, 17, 15, 10) + _fill(p, rp, QColor("#d0d8e8"), _OUT, 1.2) + # Pivote + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(_C_ELEC)) + p.drawEllipse(QRectF(10, 8, 4, 4)) + return _finish(p, px) + + +def _ico_sounding(): + """Sondeos — tubería de medición con nivel.""" + px, p = _canvas() + # Tubería + tube = _rect_path(8, 2, 8, 20, 2) + _fill(p, tube, QColor("#d8eef8"), _OUT, 1.4) + # Líquido interior + liquid = _rect_path(8, 13, 8, 9, 2) + _fill(p, liquid, _C_TANK, _OUT, 0.8) + # Marcas de escala + for y, is_long in [(4,True),(6,False),(8,True),(10,False),(12,True),(14,False),(16,True),(18,False)]: + l = 5 if is_long else 3 + p.setPen(_pen(_OUT, 1.2 if is_long else 0.8)) + p.drawLine(QPointF(8, y), QPointF(8+l, y)) + # Menisco + p.setPen(_pen(_C_WHITE, 1.6)) + p.drawLine(QPointF(8, 13), QPointF(16, 13)) + return _finish(p, px) + + +def _ico_calc_kg(): + """Calcular KG — buque azul + punto G amarillo.""" + px, p = _canvas() + # Casco + hull = QPainterPath() + hull.moveTo(2, 16); hull.cubicTo(4, 13, 8, 12, 14, 12) + hull.lineTo(21, 12); hull.lineTo(21, 16); hull.closeSubpath() + _fill(p, hull, _C_HULL, _OUT, 1.4) + # Flotación + p.setPen(_pen(_C_WATER, 1.6)) + p.drawLine(QPointF(1, 17), QPointF(23, 17)) + # Punto G (centro de gravedad) — círculo amarillo + p.setPen(_pen(_OUT, 1.4)) + p.setBrush(QBrush(_C_ELEC)) + p.drawEllipse(QRectF(8, 7, 8, 8)) + f = QFont("Arial", 6, QFont.Weight.Bold) + p.setFont(f) + p.setPen(_pen(_OUT, 1.0)) + p.drawText(QRectF(8, 7, 8, 8), Qt.AlignmentFlag.AlignCenter, "G") + # Flecha K→G + p.setPen(_pen(_C_SMOOTH, 1.6)) + p.drawLine(QPointF(12, 15), QPointF(12, 12)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SISTEMAS — Eléctrico +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_epla(): + """EPLA — rayo eléctrico amarillo sobre fondo oscuro.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 4) + _fill(p, bg, QColor("#2a2010"), _OUT, 1.4) + bolt = QPainterPath() + bolt.moveTo(15, 2); bolt.lineTo(8, 13) + bolt.lineTo(13, 13); bolt.lineTo(8, 22) + bolt.lineTo(19, 10); bolt.lineTo(13, 10) + bolt.closeSubpath() + _fill(p, bolt, _C_ELEC, QColor("#c09010"), 1.4) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SISTEMAS — Fluidos +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_fuel(): + """Combustible — gota naranja con llama interior.""" + px, p = _canvas() + # Gota naranja + drop = QPainterPath() + drop.moveTo(12, 2) + drop.cubicTo(19, 9, 19, 16, 12, 21) + drop.cubicTo(5, 16, 5, 9, 12, 2) + _fill(p, drop, _C_FUEL, _OUT, 1.6) + # Llama roja interior + flame = QPainterPath() + flame.moveTo(12, 17) + flame.cubicTo(10, 13, 11, 9, 13, 12) + flame.cubicTo(14, 9, 16, 7, 12, 10) + p.setPen(_pen(_C_FIRE, 1.6)) + p.drawPath(flame) + return _finish(p, px) + + +def _ico_freshwater(): + """Agua Dulce — grifo cyan con gota azul.""" + px, p = _canvas() + # Grifo (T) + p.setPen(_pen(_OUT, 2.2)) + p.drawLine(QPointF(7, 8), QPointF(17, 8)) + p.drawLine(QPointF(12, 8), QPointF(12, 14)) + valve = _rect_path(10, 13, 4, 3, 1) + _fill(p, valve, _C_HYDRO, _OUT, 1.4) + # Gota cayendo + drop = QPainterPath() + drop.moveTo(12, 17) + drop.cubicTo(16, 19, 16, 23, 12, 23) + drop.cubicTo(8, 23, 8, 19, 12, 17) + _fill(p, drop, _C_WATER, _OUT, 1.4) + return _finish(p, px) + + +def _ico_bilge(): + """Achique — bomba de achique (círculo + tuberías).""" + px, p = _canvas() + # Cuerpo bomba + p.setPen(_pen(_OUT, 1.6)) + p.setBrush(QBrush(_C_STRUCT)) + p.drawEllipse(QRectF(6, 6, 12, 12)) + p.setBrush(Qt.BrushStyle.NoBrush) + # Tuberías + p.setPen(_pen(_C_WATER, 2.4)) + p.drawLine(QPointF(12, 6), QPointF(12, 2)) + p.drawLine(QPointF(18, 12), QPointF(22, 12)) + p.drawLine(QPointF(12, 18), QPointF(12, 22)) + # Flechas de flujo + p.setPen(_pen(_C_WATER, 1.6)) + p.drawLine(QPointF(12, 2), QPointF(10, 4)) + p.drawLine(QPointF(12, 2), QPointF(14, 4)) + return _finish(p, px) + + +def _ico_firefight(): + """Contra Incendios — extintor rojo.""" + px, p = _canvas() + # Cuerpo extintor + body = QPainterPath() + body.addRoundedRect(QRectF(7, 9, 10, 13), 4, 4) + _fill(p, body, _C_FIRE, _OUT, 1.6) + # Cuello + boquilla + p.setPen(_pen(_OUT, 1.8)) + p.drawLine(QPointF(12, 9), QPointF(12, 6)) + p.drawLine(QPointF(9, 6), QPointF(15, 6)) + p.drawLine(QPointF(15, 6), QPointF(19, 4)) + # Chorro (cyan) + p.setPen(_pen(_C_WATER, 2.0)) + p.drawLine(QPointF(19, 4), QPointF(22, 2)) + p.drawLine(QPointF(19, 4), QPointF(21, 6)) + # Cruz blanca en el cuerpo + p.setPen(_pen(_C_WHITE, 1.8)) + p.drawLine(QPointF(12, 12), QPointF(12, 19)) + p.drawLine(QPointF(9, 15), QPointF(15, 15)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SISTEMAS — Routing 3D +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_pipes(): + """Tuberías 3D — tubería con codo 90°.""" + px, p = _canvas() + # Tubería horizontal (gris doble línea) + p.setPen(_pen(_C_STRUCT, 4.0)) + p.drawLine(QPointF(2, 13), QPointF(13, 13)) + # Codo y tubería vertical + arc = QPainterPath() + arc.moveTo(13, 13); arc.quadTo(18, 13, 18, 8) + p.setPen(_pen(_C_STRUCT, 4.0)) + p.drawPath(arc) + p.drawLine(QPointF(18, 8), QPointF(18, 2)) + # Bridas (amarillo) + p.setPen(_pen(_C_ELEC, 2.4)) + p.drawLine(QPointF(2, 11), QPointF(2, 15)) + p.drawLine(QPointF(18, 2), QPointF(18, 4)) + return _finish(p, px) + + +def _ico_cables(): + """Cableados 3D — cable ondulante amarillo.""" + px, p = _canvas() + p.setPen(_pen(_C_ELEC, 2.8)) + cable = QPainterPath() + cable.moveTo(2, 12) + cable.cubicTo(5, 5, 9, 19, 13, 12) + cable.cubicTo(16, 6, 19, 15, 22, 10) + p.drawPath(cable) + # Terminales (negro) + p.setPen(_pen(_OUT, 1.4)) + p.setBrush(QBrush(_C_STRUCT)) + p.drawEllipse(QRectF(0, 10, 4, 4)) + p.drawEllipse(QRectF(20, 8, 4, 4)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SISTEMAS — Clima / Control +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_hvac(): + """HVAC — ventilador cyan con 4 aspas.""" + px, p = _canvas() + # Carcasa + p.setPen(_pen(_OUT, 1.6)) + p.setBrush(QBrush(QColor("#d8f4f8"))) + p.drawEllipse(QRectF(2, 2, 20, 20)) + p.setBrush(Qt.BrushStyle.NoBrush) + # Aspas + cx, cy = 12.0, 12.0 + p.setPen(_pen(_C_HYDRO, 1.8)) + for i in range(4): + a = math.radians(i * 90) + r1, r2 = 2.5, 7.5 + blade = QPainterPath() + blade.moveTo(cx + r1*math.cos(a), cy + r1*math.sin(a)) + blade.quadTo( + cx + r2*0.7*math.cos(a+0.4) + math.sin(a)*4, + cy + r2*0.7*math.sin(a+0.4) - math.cos(a)*4, + cx + r2*math.cos(a+0.7), cy + r2*math.sin(a+0.7) + ) + p.drawPath(blade) + # Eje central + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(_C_HYDRO)) + p.drawEllipse(QRectF(9, 9, 6, 6)) + return _finish(p, px) + + +def _ico_steering(): + """Gobierno — rueda de gobierno de 6 radios.""" + px, p = _canvas() + # Aro exterior + p.setPen(_pen(_OUT, 1.8)) + p.setBrush(QBrush(QColor("#d0d8e8"))) + p.drawEllipse(QRectF(2, 2, 20, 20)) + p.setBrush(Qt.BrushStyle.NoBrush) + # 6 radios + cx, cy = 12.0, 12.0 + p.setPen(_pen(_C_STRUCT, 2.0)) + for i in range(6): + a = math.radians(i * 60) + p.drawLine( + QPointF(cx + 3.5*math.cos(a), cy + 3.5*math.sin(a)), + QPointF(cx + 9*math.cos(a), cy + 9*math.sin(a)) + ) + # Cubo central + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(_C_STRUCT)) + p.drawEllipse(QRectF(8.5, 8.5, 7, 7)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# FABRICACIÓN — CNC +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_materials(): + """Materiales — capas de material apiladas en colores.""" + px, p = _canvas() + layers = [ + (3, 4, _C_HULL), + (3, 8, _C_HYDRO), + (3, 12, _C_SMOOTH), + (3, 16, _C_RES), + (3, 20, _C_FAB), + ] + for x, y, col in layers: + layer = _rect_path(x, y, 18, 3, 1) + _fill(p, layer, col, _OUT, 1.0) + return _finish(p, px) + + +def _ico_nesting(): + """Nesting — piezas de colores encajadas en plancha.""" + px, p = _canvas() + board = _rect_path(1, 1, 22, 22, 2) + _fill(p, board, QColor("#e8e8f0"), _OUT, 1.4) + pieces = [ + (2,2,10,6, _C_HULL), + (13,2,9,7, _C_SMOOTH), + (2,9,7,13, _C_RES), + (10,9,13,13, _C_FAB), + (2,17,20,5, _C_HYDRO), + ] + for x,y,w,h,col in pieces: + r = _rect_path(x, y, w, h, 1) + _fill(p, r, col, _OUT, 1.0) + return _finish(p, px) + + +def _ico_gcode(): + """G-code — pantalla de terminal con instrucciones.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#0a1020"), _OUT, 1.4) + f = QFont("Courier New", 5, QFont.Weight.Bold) + p.setFont(f) + for y, text, col in [(6,"G00 X10", _C_SMOOTH), + (11,"G01 Z-5", _C_MINT), + (16,"M03 S800", _C_ELEC), + (21,"M30", QColor("#a0b0c8"))]: + p.setPen(_pen(col, 1.0)) + p.drawText(QRectF(2, y-4, 20, 6), Qt.AlignmentFlag.AlignLeft, text) + return _finish(p, px) + + +def _ico_postproc(): + """Post-Procesador — rueda dentada gris.""" + px, p = _canvas() + cx, cy = 12.0, 12.0 + n_teeth = 8 + path = QPainterPath() + for i in range(n_teeth * 2): + a = math.radians(i * 180 / n_teeth) + r = 9.0 if i % 2 == 0 else 6.5 + x = cx + r * math.cos(a) + y = cy + r * math.sin(a) + if i == 0: + path.moveTo(x, y) + else: + path.lineTo(x, y) + path.closeSubpath() + _fill(p, path, _C_STRUCT, _OUT, 1.4) + # Hueco central + p.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(Qt.GlobalColor.transparent)) + p.drawEllipse(QRectF(8, 8, 8, 8)) + p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) + p.setPen(_pen(_OUT, 1.4)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(QRectF(8, 8, 8, 8)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# FABRICACIÓN — Moldes FRP +# ═══════════════════════════════════════════════════════════════════════════════ + +def _ico_lofting(): + """Lofting — cuadernas de molde con curvas longitudinales.""" + px, p = _canvas() + # Cuadernas (secciones) + p.setPen(_pen(_C_FAB, 1.8)) + for x, yt in [(4, 11), (9, 7), (15, 7), (20, 11)]: + p.drawLine(QPointF(x, yt), QPointF(x, 21)) + p.drawLine(QPointF(x-2, 21), QPointF(x+2, 21)) + # Línea de cubierta (curva longitudinal) + p.setPen(_pen(_C_RES, 2.2)) + top = QPainterPath() + top.moveTo(4, 11); top.cubicTo(7, 6, 17, 6, 20, 11) + p.drawPath(top) + # Línea de quilla + p.setPen(_pen(_C_HYDRO, 2.0)) + p.drawLine(QPointF(4, 21), QPointF(20, 21)) + return _finish(p, px) + + +def _ico_laminate(): + """Laminado — capas de fibra en ángulos distintos.""" + px, p = _canvas() + layers = [(0, _C_FAB), (45, _C_HULL), (-45, _C_SMOOTH), (90, _C_ELEC)] + for i, (angle, col) in enumerate(layers): + y = 4 + i * 5 + p.setPen(QPen(col, 3.5)) + a = math.radians(angle) + ca, sa = math.cos(a), math.sin(a) + for x0 in range(4, 22, 4): + p.drawLine( + QPointF(x0 - 3*ca, y - 3*sa), + QPointF(x0 + 3*ca, y + 3*sa) + ) + return _finish(p, px) + + +def _ico_resin(): + """Resina — cubo con gota cayendo.""" + px, p = _canvas() + # Cubo/contenedor + bucket = _rect_path(4, 13, 16, 10, 2) + _fill(p, bucket, _C_HYDRO, _OUT, 1.6) + # Asa + p.setPen(_pen(_OUT, 1.8)) + arc = QPainterPath() + arc.moveTo(6, 13); arc.quadTo(6, 7, 12, 7); arc.quadTo(18, 7, 18, 13) + p.drawPath(arc) + # Gota cayendo (cyan) + drop = QPainterPath() + drop.moveTo(12, 3) + drop.cubicTo(15, 6, 15, 10, 12, 11) + drop.cubicTo(9, 10, 9, 6, 12, 3) + _fill(p, drop, _C_WATER, _OUT, 1.4) + return _finish(p, px) + + +def _ico_bom(): + """BOM de materiales — lista estructurada de colores.""" + px, p = _canvas() + bg = _rect_path(1, 1, 22, 22, 3) + _fill(p, bg, QColor("#f0f0f8"), _OUT, 1.2) + rows = [(5, _C_HULL), (9, _C_HYDRO), (13, _C_SMOOTH), (17, _C_RES), (21, _C_FAB)] + for y, col in rows: + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(col)) + p.drawEllipse(QRectF(3, y-2.5, 5, 5)) + p.setPen(_pen(_OUT, 1.4)) + p.drawLine(QPointF(10, y), QPointF(21, y)) + return _finish(p, px) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Registro y función pública +# ═══════════════════════════════════════════════════════════════════════════════ + +_REGISTRY: dict[str, Callable[[], QIcon]] = { + "lines_plan": _ico_lines_plan, + "4views": _ico_4views, + "wizard": _ico_wizard, + "hull_nurbs": _ico_hull_nurbs, + "appendage": _ico_appendage, + "ctrl_pts": _ico_ctrl_pts, + "extrude": _ico_extrude, + "mirror": _ico_mirror, + "lackenby": _ico_lackenby, + "import_offsets": _ico_import_offsets, + "import_dxf": _ico_import_dxf, + "export_iges": _ico_export_iges, + "export_step": _ico_export_step, + "export_dxf": _ico_export_dxf, + "smooth": _ico_smooth, + "combs": _ico_combs, + "fairness": _ico_fairness, + "hydro_calc": _ico_hydro_calc, + "hydro_curves": _ico_hydro_curves, + "export_csv": _ico_export_csv, + "gz_curve": _ico_gz_curve, + "imo": _ico_imo, + "damage": _ico_damage, + "holtrop": _ico_holtrop, + "savitsky": _ico_savitsky, + "vpp": _ico_vpp, + "stf": _ico_stf, + "spectrum": _ico_spectrum, + "iso12215": _ico_iso12215, + "new_tank": _ico_new_tank, + "model_tank": _ico_model_tank, + "load_case": _ico_load_case, + "sounding": _ico_sounding, + "calc_kg": _ico_calc_kg, + "epla": _ico_epla, + "fuel": _ico_fuel, + "freshwater": _ico_freshwater, + "bilge": _ico_bilge, + "firefight": _ico_firefight, + "pipes": _ico_pipes, + "cables": _ico_cables, + "hvac": _ico_hvac, + "steering": _ico_steering, + "materials": _ico_materials, + "nesting": _ico_nesting, + "gcode": _ico_gcode, + "postproc": _ico_postproc, + "lofting": _ico_lofting, + "laminate": _ico_laminate, + "resin": _ico_resin, + "bom": _ico_bom, +} + +_CACHE: dict[str, QIcon] = {} + + +def icon(name: str) -> QIcon: + """Devuelve el QIcon para la clave *name* (con caché). + + Si la clave no existe devuelve QIcon() vacío en lugar de lanzar excepción. + """ + if name not in _CACHE: + fn = _REGISTRY.get(name) + _CACHE[name] = fn() if fn is not None else QIcon() + return _CACHE[name] diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index ec34a83..919f4af 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -21,7 +21,7 @@ import json from pathlib import Path from typing import Optional -from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtCore import Qt, QSize, Signal, QThread, QObject from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon from PySide6.QtWidgets import ( QApplication, @@ -37,6 +37,7 @@ from PySide6.QtWidgets import ( QSplitter, QStackedWidget, QToolBar, + QPushButton, QToolButton, QVBoxLayout, QWidget, @@ -46,6 +47,7 @@ from PySide6.QtWidgets import ( from arshipdesign import __version__ from arshipdesign.core.project import Project +from arshipdesign.ui.icons import icon as _ico from arshipdesign.utils.logger import get_logger from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008 from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget @@ -184,12 +186,15 @@ _VIEW_LABELS: dict[str, str] = { class ViewportFrame(QFrame): - """Un viewport individual con barra de título.""" + """Un viewport individual con barra de título y botón de maximizar.""" + + maximize_requested = Signal(str) # emite view_type al pedir maximizar/restaurar def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.view_type = view_type self.setObjectName("viewportFrame") + self._maximized = False self._build_ui() def _build_ui(self) -> None: @@ -197,19 +202,30 @@ class ViewportFrame(QFrame): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # ── Barra de título (objectName único por vista) ────────── + # ── Barra de título ─────────────────────────────────────── title_bar = QWidget() - # p.ej. "viewportTitleBar_perspective", "viewportTitleBar_profile"… title_bar.setObjectName(f"viewportTitleBar_{self.view_type}") title_bar.setFixedHeight(24) + title_bar.setCursor(Qt.CursorShape.PointingHandCursor) tbl = QHBoxLayout(title_bar) tbl.setContentsMargins(10, 0, 4, 0) tbl.setSpacing(0) - lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper()) - lbl.setObjectName(f"viewportTitle_{self.view_type}") - tbl.addWidget(lbl) + self._title_lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper()) + self._title_lbl.setObjectName(f"viewportTitle_{self.view_type}") + tbl.addWidget(self._title_lbl) tbl.addStretch() + + # Botón maximizar / restaurar + self._max_btn = QPushButton("⬜") + self._max_btn.setObjectName("viewportMaxBtn") + self._max_btn.setFixedSize(20, 20) + self._max_btn.setFlat(True) + self._max_btn.setToolTip("Maximizar / Restaurar (doble clic en la barra)") + self._max_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._max_btn.clicked.connect(lambda: self.maximize_requested.emit(self.view_type)) + tbl.addWidget(self._max_btn) + layout.addWidget(title_bar) # ── Área de dibujo (placeholder Sprint 0) ──────────────── @@ -224,6 +240,14 @@ class ViewportFrame(QFrame): cl.addWidget(ph) layout.addWidget(self._canvas, 1) + # Doble clic en la barra también maximiza + title_bar.mouseDoubleClickEvent = lambda _e: self.maximize_requested.emit(self.view_type) + + def set_maximized_icon(self, maximized: bool) -> None: + """Actualiza el icono del botón según el estado.""" + self._max_btn.setText("❎" if maximized else "⬜") + self._max_btn.setToolTip("Restaurar (doble clic)" if maximized else "Maximizar (doble clic)") + def set_canvas(self, widget: QWidget) -> None: """Sprint 1: sustituye el placeholder por el widget 3D / 2D real.""" lo = self.layout() @@ -249,6 +273,7 @@ class FourViewport(QWidget): def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.setObjectName("fourViewport") + self._maximized_view: Optional[str] = None self._build_ui() def _build_ui(self) -> None: @@ -256,34 +281,39 @@ class FourViewport(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - v_split = QSplitter(Qt.Orientation.Vertical) - v_split.setObjectName("viewportSplitter") - v_split.setHandleWidth(5) + self._v_split = QSplitter(Qt.Orientation.Vertical) + self._v_split.setObjectName("viewportSplitter") + self._v_split.setHandleWidth(5) - top_split = QSplitter(Qt.Orientation.Horizontal) - top_split.setObjectName("viewportSplitter") - top_split.setHandleWidth(5) + self._top_split = QSplitter(Qt.Orientation.Horizontal) + self._top_split.setObjectName("viewportSplitter") + self._top_split.setHandleWidth(5) self._vp_perspective = ViewportFrame("perspective") self._vp_profile = ViewportFrame("profile") - top_split.addWidget(self._vp_perspective) - top_split.addWidget(self._vp_profile) - top_split.setSizes([600, 600]) + self._top_split.addWidget(self._vp_perspective) + self._top_split.addWidget(self._vp_profile) + self._top_split.setSizes([600, 600]) - bot_split = QSplitter(Qt.Orientation.Horizontal) - bot_split.setObjectName("viewportSplitter") - bot_split.setHandleWidth(5) + self._bot_split = QSplitter(Qt.Orientation.Horizontal) + self._bot_split.setObjectName("viewportSplitter") + self._bot_split.setHandleWidth(5) self._vp_bodyplan = ViewportFrame("bodyplan") self._vp_plan = ViewportFrame("plan") - bot_split.addWidget(self._vp_bodyplan) - bot_split.addWidget(self._vp_plan) - bot_split.setSizes([600, 600]) + self._bot_split.addWidget(self._vp_bodyplan) + self._bot_split.addWidget(self._vp_plan) + self._bot_split.setSizes([600, 600]) - v_split.addWidget(top_split) - v_split.addWidget(bot_split) - v_split.setSizes([400, 400]) - layout.addWidget(v_split) + self._v_split.addWidget(self._top_split) + self._v_split.addWidget(self._bot_split) + self._v_split.setSizes([400, 400]) + layout.addWidget(self._v_split) - def viewport(self, view_type: str) -> Optional[ViewportFrame]: + # Conectar señal de maximizar de cada frame + for vp in (self._vp_perspective, self._vp_profile, + self._vp_bodyplan, self._vp_plan): + vp.maximize_requested.connect(self.toggle_maximize) + + def viewport(self, view_type: str) -> Optional["ViewportFrame"]: return { "perspective": self._vp_perspective, "profile": self._vp_profile, @@ -291,6 +321,59 @@ class FourViewport(QWidget): "plan": self._vp_plan, }.get(view_type) + def toggle_maximize(self, view_type: str) -> None: + """Maximiza el viewport indicado, o restaura el layout de 4 paneles.""" + if self._maximized_view == view_type: + self._restore_layout() + else: + self._maximize(view_type) + + def _maximize(self, view_type: str) -> None: + """Colapsa los otros tres paneles y expande el solicitado.""" + companion_map = { + "perspective": self._vp_profile, + "profile": self._vp_perspective, + "bodyplan": self._vp_plan, + "plan": self._vp_bodyplan, + } + # Ocultar el otro viewport de la misma fila + companion_map[view_type].hide() + # Ocultar la fila opuesta completa + if view_type in ("perspective", "profile"): + self._bot_split.hide() + else: + self._top_split.hide() + + self._maximized_view = view_type + all_vps = { + "perspective": self._vp_perspective, + "profile": self._vp_profile, + "bodyplan": self._vp_bodyplan, + "plan": self._vp_plan, + } + for name, vp in all_vps.items(): + vp.set_maximized_icon(name == view_type) + + def _restore_layout(self) -> None: + """Restaura el layout normal de 4 paneles iguales.""" + for vp in (self._vp_perspective, self._vp_profile, + self._vp_bodyplan, self._vp_plan): + vp.show() + self._top_split.show() + self._bot_split.show() + + # Restaurar proporciones iguales + w = max(self.width(), 100) + h = max(self.height(), 100) + self._v_split.setSizes([h // 2, h // 2]) + self._top_split.setSizes([w // 2, w // 2]) + self._bot_split.setSizes([w // 2, w // 2]) + + self._maximized_view = None + for vp in (self._vp_perspective, self._vp_profile, + self._vp_bodyplan, self._vp_plan): + vp.set_maximized_icon(False) + # ───────────────────────────────────────────────────────────────────────────── # PANEL DE CAPAS (estilo DELFTship) @@ -792,6 +875,54 @@ class RibbonBar(QWidget): return group +# ───────────────────────────────────────────────────────────────────────────── +# WORKER: cálculo GZ en hilo separado (evita freeze del UI thread) +# ───────────────────────────────────────────────────────────────────────────── + +class _HydroWorker(QObject): + """Calcula HydrostaticCurves en un hilo secundario.""" + finished = Signal(object) # HydrostaticCurves + error = Signal(str) + + def __init__(self, hull, n_points: int = 30, rho: float = 1025.0) -> None: + super().__init__() + self._hull = hull + self._n_points = n_points + self._rho = rho + + def run(self) -> None: + try: + from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves + curves = HydrostaticCurves.compute( + self._hull, n_points=self._n_points, rho=self._rho + ) + self.finished.emit(curves) + except Exception as exc: + self.error.emit(str(exc)) + + +class _GZWorker(QObject): + """Ejecuta compute_gz_wall_sided en un QThread para no bloquear la UI.""" + + finished = Signal(object, object) # (GZCurve, ImoResult) + error = Signal(str) + + def __init__(self, hull, draft: float, kg: float) -> None: + super().__init__() + self._hull = hull + self._draft = draft + self._kg = kg + + def run(self) -> None: + try: + from arshipdesign.stability import compute_gz_wall_sided, check_imo_is2008 + gz_curve = compute_gz_wall_sided(self._hull, self._draft, kg=self._kg) + imo_result = check_imo_is2008(gz_curve) + self.finished.emit(gz_curve, imo_result) + except Exception as exc: + self.error.emit(str(exc)) + + # ───────────────────────────────────────────────────────────────────────────── # VENTANA PRINCIPAL # ───────────────────────────────────────────────────────────────────────────── @@ -816,6 +947,15 @@ class MainWindow(QMainWindow): self._project: Optional[Project] = None self._current_hull = None # Hull activo en todos los visores self._gz_widget: Optional[GZCurveWidget] = None + self._gz_thread: Optional[QThread] = None + self._gz_worker = None + self._hydro_thread: Optional[QThread] = None + self._hydro_worker = None + # ── Historial de deshacer / rehacer ─────────────────────────── + self._undo_stack: list[dict] = [] # estados anteriores (más viejo → índice 0) + self._redo_stack: list[dict] = [] # estados rehechos disponibles + self._last_hull_state: Optional[dict] = None # snapshot ANTES del último edit + self._MAX_UNDO = 50 self._lang = get_language() self._strings = _load_i18n(self._lang) self._setup_ui() @@ -866,10 +1006,17 @@ class MainWindow(QMainWindow): # Edición live durante drag → actualizar vistas cruzadas sin resetear zoom self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging) + self._viewer_profile.offsets_dragging.connect(self._on_offsets_dragging) self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging) # Fin del drag → persistir + actualizar 3D + hidrostáticos self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer) + self._viewer_profile.offsets_edited.connect(self._on_offsets_edited_from_viewer) self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer) + # Selección cruzada de nodo: resaltar en las otras dos vistas + self._viewer_bodyplan.node_selected.connect(self._on_node_selected_in_viewer) + self._viewer_profile.node_selected.connect(self._on_node_selected_in_viewer) + self._viewer_plan.node_selected.connect(self._on_node_selected_in_viewer) + # Zoom independiente por visor — sin sincronización de escala # Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS) from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor @@ -950,117 +1097,131 @@ class MainWindow(QMainWindow): g.add_button(_spi(sp.SP_ArrowForward), "Rehacer", "Rehacer Ctrl+Y", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Vistas") - g.add_button(_spi(sp.SP_DesktopIcon), "4 Vistas", "4 Viewports F2", + g.add_button(_ico("4views"), "4 Vistas", "4 Viewports F2", lambda: self._module_area.activate(M.MOD_4VP)) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Líneas", "Plano de Líneas F3", + g.add_button(_ico("lines_plan"), "Líneas", "Plano de Líneas F3", lambda: self._module_area.activate(M.MOD_LINES)) - g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4", + g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4", lambda: self._module_area.activate(M.MOD_OFFSETS)) # ── Tab GEOMETRÍA ───────────────────────────────────────── g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Nuevo") - g.add_button(_spi(sp.SP_FileIcon), "Asistente", "Asistente de embarcación", enabled=False) - g.add_button(_spi(sp.SP_FileIcon), "Casco NURBS", "Nuevo casco NURBS", enabled=False) - g.add_button(_spi(sp.SP_FileIcon), "Apéndice", "Añadir apéndice", enabled=False) + g.add_button(_ico("wizard"), "Asistente", "Asistente de embarcación", enabled=False) + g.add_button(_ico("hull_nurbs"), "Casco NURBS", "Nuevo casco NURBS", enabled=False) + g.add_button(_ico("appendage"), "Apéndice", "Añadir apéndice", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Edición NURBS") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Pts. Ctrl.", "Editar puntos de control", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Extruir", "Extruir sección", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espejo", "Espejo por eje de crujía", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Lackenby", "Transformación de Lackenby", enabled=False) + g.add_button(_ico("ctrl_pts"), "Pts. Ctrl.", "Editar puntos de control", enabled=False) + g.add_button(_ico("extrude"), "Extruir", "Extruir sección", enabled=False) + g.add_button(_ico("mirror"), "Espejo", "Espejo por eje de crujía", enabled=False) + g.add_button(_ico("lackenby"), "Lackenby", "Transformación de Lackenby", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Importar") - g.add_button(_spi(sp.SP_DirOpenIcon), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False) - g.add_button(_spi(sp.SP_DirOpenIcon), "DXF", "Importar plano DXF", enabled=False) + g.add_button(_ico("import_offsets"), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False) + g.add_button(_ico("import_dxf"), "DXF", "Importar plano DXF", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar") - g.add_button(_spi(sp.SP_DialogSaveButton), "IGES", "Exportar IGES", enabled=False) - g.add_button(_spi(sp.SP_DialogSaveButton), "STEP", "Exportar STEP", enabled=False) - g.add_button(_spi(sp.SP_DialogSaveButton), "DXF", "Exportar DXF", enabled=False) + g.add_button(_ico("export_iges"), "IGES", "Exportar IGES", enabled=False) + g.add_button(_ico("export_step"), "STEP", "Exportar STEP", enabled=False) + g.add_button(_ico("export_dxf"), "DXF", "Exportar DXF", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Suavizado") + g.add_button(_ico("smooth"), + "Suavizar", + "Un paso de suavizado Laplaciano global — presiona varias veces", + self._fair_hull_step) + g.add_button(_ico("combs"), + "Peines", + "[C] Mostrar/ocultar peines de curvatura — selecciona una curva con Shift+clic primero", + self._toggle_curvature_display) + g.add_button(_ico("fairness"), + "Equidad", + "[F] Mostrar/ocultar coloreo de equidad (verde=suave, rojo=quiebre)", + self._toggle_fairness_display) # ── Tab ANÁLISIS ────────────────────────────────────────── g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular curvas hidrostáticas", + g.add_button(_ico("hydro_calc"), "Calcular", "Calcular curvas hidrostáticas", self._on_compute_hydrostatics) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas", + g.add_button(_ico("hydro_curves"), "Curvas", "Curvas hidrostáticas", self._on_show_hydrostatics) - g.add_button(_spi(sp.SP_DialogSaveButton), "Exp. CSV", "Exportar curvas como CSV", + g.add_button(_ico("export_csv"), "Exp. CSV", "Exportar curvas como CSV", self._on_export_hydrostatics_csv) g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática", + g.add_button(_ico("gz_curve"), "Curva GZ", "Curva GZ estática", self._on_show_stability) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False) + g.add_button(_ico("imo"), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False) + g.add_button(_ico("damage"), "Avería", "Estabilidad en avería", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Resistencia") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Holtrop", "Holtrop & Mennen", + g.add_button(_ico("holtrop"), "Holtrop", "Holtrop & Mennen", lambda: self._module_area.activate(M.MOD_RESISTANCE), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Savitsky", "Savitsky (planeo)", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "VPP", "VPP Velero / DSYHS", + g.add_button(_ico("savitsky"), "Savitsky", "Savitsky (planeo)", enabled=False) + g.add_button(_ico("vpp"), "VPP", "VPP Velero / DSYHS", lambda: self._module_area.activate(M.MOD_VPP), False) g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Seakeeping") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "STF", "Strip Theory (STF)", + g.add_button(_ico("stf"), "STF", "Strip Theory (STF)", lambda: self._module_area.activate(M.MOD_SEAKEEPING), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espectro", "Espectro de respuesta", enabled=False) + g.add_button(_ico("spectrum"), "Espectro", "Espectro de respuesta", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estructura") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "ISO 12215", "Escantillado ISO 12215-5", + g.add_button(_ico("iso12215"), "ISO 12215", "Escantillado ISO 12215-5", lambda: self._module_area.activate(M.MOD_SCANTLING), False) # ── Tab TANQUES ─────────────────────────────────────────── g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Tanques") - g.add_button(_spi(sp.SP_FileIcon), "Nuevo Tq.", "Definir nuevo tanque", + g.add_button(_ico("new_tank"), "Nuevo Tq.", "Definir nuevo tanque", lambda: self._module_area.activate(M.MOD_TANKS), False) - g.add_button(_spi(sp.SP_FileIcon), "Modelar", "Modelar tanque NURBS", enabled=False) + g.add_button(_ico("model_tank"), "Modelar", "Modelar tanque NURBS", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Casos de Carga") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Nuevo caso", "Definir caso de carga", enabled=False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Sondeos", "Tablas de sondeo", + g.add_button(_ico("load_case"), "Nuevo caso", "Definir caso de carga", enabled=False) + g.add_button(_ico("sounding"), "Sondeos", "Tablas de sondeo", lambda: self._module_area.activate(M.MOD_CAPACITY), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calc. KG", "Calcular KG por caso", enabled=False) + g.add_button(_ico("calc_kg"), "Calc. KG", "Calcular KG por caso", enabled=False) # ── Tab SISTEMAS ────────────────────────────────────────── g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Eléctrico") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "EPLA", "Balance eléctrico (EPLA)", + g.add_button(_ico("epla"), "EPLA", "Balance eléctrico (EPLA)", lambda: self._module_area.activate(M.MOD_ELECTRICAL), False) g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Fluidos") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Combustible", "Sistema de combustible", + g.add_button(_ico("fuel"), "Combustible", "Sistema de combustible", lambda: self._module_area.activate(M.MOD_FUEL), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Agua Dulce", "Sistema de agua dulce", + g.add_button(_ico("freshwater"), "Agua Dulce", "Sistema de agua dulce", lambda: self._module_area.activate(M.MOD_FRESHWATER), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Achique", "Sistema de achique", + g.add_button(_ico("bilge"), "Achique", "Sistema de achique", lambda: self._module_area.activate(M.MOD_BILGE), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "C. Incendio", "Sistema contra incendios", + g.add_button(_ico("firefight"), "C. Incendio", "Sistema contra incendios", lambda: self._module_area.activate(M.MOD_FIREFIGHT), False) g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Routing 3D") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Tuberías", "Routing de tuberías 3D", + g.add_button(_ico("pipes"), "Tuberías", "Routing de tuberías 3D", lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Cableados", "Routing de cableados 3D", + g.add_button(_ico("cables"), "Cableados", "Routing de cableados 3D", lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), False) g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Clima / Control") - g.add_button(_spi(sp.SP_FileDialogDetailedView), "HVAC", "Sistema HVAC", + g.add_button(_ico("hvac"), "HVAC", "Sistema HVAC", lambda: self._module_area.activate(M.MOD_HVAC), False) - g.add_button(_spi(sp.SP_FileDialogDetailedView), "Gobierno", "Sistema de gobierno", enabled=False) + g.add_button(_ico("steering"), "Gobierno", "Sistema de gobierno", enabled=False) # ── Tab FABRICACIÓN ─────────────────────────────────────── g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "CNC") - g.add_button(_spi(sp.SP_FileDialogContentsView), "Materiales", "Estimación de materiales", enabled=False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "Nesting", "Optimización de cortes (nesting)", + g.add_button(_ico("materials"), "Materiales", "Estimación de materiales", enabled=False) + g.add_button(_ico("nesting"), "Nesting", "Optimización de cortes (nesting)", lambda: self._module_area.activate(M.MOD_CNC), False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "G-code", "Generar G-code", enabled=False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "Post-Proc.", "Configurar post-procesador CNC", enabled=False) + g.add_button(_ico("gcode"), "G-code", "Generar G-code", enabled=False) + g.add_button(_ico("postproc"), "Post-Proc.", "Configurar post-procesador CNC", enabled=False) g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "Moldes FRP") - g.add_button(_spi(sp.SP_FileDialogContentsView), "Lofting", "Lofting del molde", + g.add_button(_ico("lofting"), "Lofting", "Lofting del molde", lambda: self._module_area.activate(M.MOD_MOLDS), False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "Laminado", "Schedule de laminado", enabled=False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "Resina", "Calculadora de resina", enabled=False) - g.add_button(_spi(sp.SP_FileDialogContentsView), "BOM", "BOM de materiales", enabled=False) + g.add_button(_ico("laminate"), "Laminado", "Schedule de laminado", enabled=False) + g.add_button(_ico("resin"), "Resina", "Calculadora de resina", enabled=False) + g.add_button(_ico("bom"), "BOM", "BOM de materiales", enabled=False) def _setup_menu(self) -> None: mb = self.menuBar() @@ -1082,8 +1243,10 @@ class MainWindow(QMainWindow): # ── EDITAR ───────────────────────────────────────────────── m = mb.addMenu("Editar") - self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, enabled=False) - self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, enabled=False) + self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, + slot=self._undo, enabled=False) + self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, + slot=self._redo, enabled=False) m.addSeparator() self._add_action(m, "Preferencias...", slot=self._on_preferences) @@ -1250,9 +1413,11 @@ class MainWindow(QMainWindow): self._project = Project.new(hull.name if hull else "Proyecto sin título") self._on_project_loaded() if hull is not None: + hull.snap_boundary_nodes_to_contours() self._current_hull = hull self._project.set_hull(hull) # persistir en ship_data self._load_hull_viewers(hull) + self._reset_undo_history(hull) self.statusBar().showMessage( f"Nuevo proyecto: {self._project.name}" ) @@ -1273,7 +1438,9 @@ class MainWindow(QMainWindow): self._on_project_loaded() self.statusBar().showMessage(f"Abierto: {path}") except Exception as e: - QMessageBox.critical(self, "Error al abrir", str(e)) + logger.error("Error al abrir proyecto '%s': %s", path, e) + QMessageBox.critical(self, "Error al abrir", + "No se pudo abrir el archivo. Consulte el log para más detalles.") def _on_save_project(self) -> None: if not self._project: @@ -1286,7 +1453,9 @@ class MainWindow(QMainWindow): self._update_title() self.statusBar().showMessage(f"Guardado: {self._project.path}") except Exception as e: - QMessageBox.critical(self, "Error al guardar", str(e)) + logger.error("Error al guardar proyecto: %s", e) + QMessageBox.critical(self, "Error al guardar", + "No se pudo guardar el archivo. Consulte el log para más detalles.") def _on_save_as(self) -> None: if not self._project: @@ -1305,7 +1474,9 @@ class MainWindow(QMainWindow): self._update_title() self.statusBar().showMessage(f"Guardado como: {path}") except Exception as e: - QMessageBox.critical(self, "Error al guardar", str(e)) + logger.error("Error al guardar proyecto como '%s': %s", path, e) + QMessageBox.critical(self, "Error al guardar", + "No se pudo guardar el archivo. Consulte el log para más detalles.") def _on_project_loaded(self) -> None: self._update_title() @@ -1315,6 +1486,7 @@ class MainWindow(QMainWindow): if hull is not None: self._current_hull = hull self._load_hull_viewers(hull) + self._reset_undo_history(hull) logger.info("Hull '%s' restaurado desde proyecto", hull.name) def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None: @@ -1323,10 +1495,11 @@ class MainWindow(QMainWindow): ``_skip_offsets_editor=True`` evita el bucle de retroalimentacion cuando la llamada proviene del propio editor de offsets. """ - # ── Visores 2D ──────────────────────────────────────────── + # ── Visores 2D — cargar y unificar escala px/m ──────────── self._viewer_bodyplan.set_hull(hull) self._viewer_profile.set_hull(hull) self._viewer_plan.set_hull(hull) + # Cada visor ajusta su propio zoom con _fit_to_view al recibir set_hull. # ── Editor de offsets ───────────────────────────────────── if not _skip_offsets_editor: self._offsets_editor.set_hull(hull) @@ -1376,7 +1549,14 @@ class MainWindow(QMainWindow): hull = self._current_hull if hull is None: return - # hull.offsets ya fue modificado in-place durante el drag. + # ── Capturar estado ANTERIOR para Deshacer (Ctrl+Z) ────────── + if self._last_hull_state is not None: + self._undo_stack.append(self._last_hull_state) + if len(self._undo_stack) > self._MAX_UNDO: + self._undo_stack.pop(0) + self._redo_stack.clear() # nueva rama invalida el redo + self._act_undo.setEnabled(True) + self._act_redo.setEnabled(False) # Invalidar caché NURBS para que to_mesh() reconstruya desde los # offsets editados y no devuelva la geometría anterior. hull.invalidate() @@ -1396,38 +1576,170 @@ class MainWindow(QMainWindow): logger.warning("Error al actualizar visor 3D: %s", exc) # Barra de hidrostáticos self._update_hydrostatics(hull) + # Guardar estado actual como referencia para el próximo Deshacer + self._last_hull_state = hull.to_dict() self.statusBar().showMessage(f"Geometría editada — {hull.name}") + # ── Deshacer / Rehacer ──────────────────────────────────────────────────── + + def _reset_undo_history(self, hull) -> None: + """Limpia ambos stacks y captura el estado inicial del casco.""" + self._undo_stack.clear() + self._redo_stack.clear() + self._last_hull_state = hull.to_dict() + self._act_undo.setEnabled(False) + self._act_redo.setEnabled(False) + + def _undo(self) -> None: + """Ctrl+Z — restaura el estado anterior al último drag/edición.""" + if not self._undo_stack or self._current_hull is None: + return + from arshipdesign.core.hull import Hull + # Guardar estado actual en redo antes de revertir + self._redo_stack.append(self._current_hull.to_dict()) + # Restaurar estado anterior + state = self._undo_stack.pop() + hull = Hull.from_dict(state) + self._current_hull = hull + if self._project is not None: + self._project.set_hull(hull) + self._load_hull_viewers(hull) + self._last_hull_state = hull.to_dict() + # Actualizar acciones + self._act_undo.setEnabled(bool(self._undo_stack)) + self._act_redo.setEnabled(True) + self.statusBar().showMessage( + f"Deshacer — {len(self._undo_stack)} paso(s) disponibles" + ) + + def _redo(self) -> None: + """Ctrl+Y — rehace el último cambio deshecho.""" + if not self._redo_stack or self._current_hull is None: + return + from arshipdesign.core.hull import Hull + # Guardar estado actual en undo + self._undo_stack.append(self._current_hull.to_dict()) + # Restaurar estado rehechos + state = self._redo_stack.pop() + hull = Hull.from_dict(state) + self._current_hull = hull + if self._project is not None: + self._project.set_hull(hull) + self._load_hull_viewers(hull) + self._last_hull_state = hull.to_dict() + # Actualizar acciones + self._act_undo.setEnabled(True) + self._act_redo.setEnabled(bool(self._redo_stack)) + self.statusBar().showMessage( + f"Rehacer — {len(self._redo_stack)} paso(s) por rehacer" + ) + + def _on_node_selected_in_viewer(self, idx) -> None: + """Propaga la selección de nodo a las otras dos vistas como anillo cian.""" + sender = self.sender() + for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan): + if v is not sender: + v.set_peer_selection(idx) + + def _on_viewer_scale_changed(self, scale: float) -> None: + """Sincroniza el zoom: aplica la misma escala px/m a los otros dos visores.""" + sender = self.sender() + for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan): + if v is not sender: + v.center_at_scale(scale) + + def _fair_hull_step(self) -> None: + """Un paso de suavizado Laplaciano sobre los offsets del casco activo. + + Aplica el filtro y[i] = 0.5·y[i] + 0.25·(y[i-1] + y[i+1]) a cada + línea de agua, preservando las estaciones de borde (AP, FP). + Presionar el botón "Suavizar" varias veces para suavizar gradualmente. + """ + import numpy as np # local — numpy no es importado en el módulo + + hull = self._current_hull + if hull is None: + return + ot = hull.offsets + if ot.n_stations < 3 or ot.n_waterlines < 3: + return + + data = ot.data.copy() + # Laplaciano 1-D sobre el eje longitudinal (estaciones), bordes fijos + for j in range(ot.n_waterlines): + for i in range(1, ot.n_stations - 1): + data[i, j] = (0.5 * data[i, j] + + 0.25 * (data[i - 1, j] + data[i + 1, j])) + ot.data[:] = np.maximum(0.0, data) + + hull.invalidate() + if self._project is not None: + self._project.set_hull(hull) + self._viewer_bodyplan.update_offsets(hull) + self._viewer_profile.update_offsets(hull) + self._viewer_plan.update_offsets(hull) + self._offsets_editor.set_hull(hull) + if self._viewer_3d is not None: + try: + self._viewer_3d.load_hull(hull) + except Exception as exc: + logger.warning("Error al actualizar visor 3D tras suavizado: %s", exc) + self._update_hydrostatics(hull) + self.statusBar().showMessage("Suavizado — un paso Laplaciano aplicado") + + def _toggle_curvature_display(self) -> None: + """Activa/desactiva los peines de curvatura en los tres visores 2D. + + Los pelos son perpendiculares a la curva — su longitud es proporcional + a la curvatura local (normalizada). Usar Shift+clic sobre una curva + para ver los pelos solo en esa curva. + """ + for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan): + v._show_curvature = not v._show_curvature + v.update() + any_on = self._viewer_bodyplan._show_curvature + self.statusBar().showMessage( + "Peines ON — Shift+clic sobre una curva para enfocar [C] para apagar" + if any_on else "Peines OFF" + ) + + def _toggle_fairness_display(self) -> None: + """Activa/desactiva el coloreo de equidad en los tres visores 2D. + + Verde = nodo suave (segunda derivada baja). + Rojo = quiebre brusco de curvatura — candidato para suavizar con [S]. + """ + for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan): + v._show_fairness = not v._show_fairness + v.update() + any_on = self._viewer_bodyplan._show_fairness + self.statusBar().showMessage( + "Equidad ON — verde=suave, rojo=quiebre [S]=suavizar nodo seleccionado" + if any_on else "Equidad OFF" + ) + def _update_hydrostatics(self, hull) -> None: """Calcula hidrostáticos al calado de diseño y actualiza la barra inferior. - Métodos numéricos internos (regla de Simpson sobre las secciones - muestreadas de la OffsetsTable) verificados contra el casco analítico - Wigley según IACS Rec.34 §4.3. + Usa compute_upright en una sola pasada (una sola integración) en lugar + de llamar a cada método del Hull por separado (que haría 9× to_sections). """ try: - T = hull.draft - delta = hull.displacement_tonnes(T) - lcb_v = hull.lcb(T) - kb = hull.vcb(T) - kmt = hull.km_transverse(T) - tpc = hull.tpc(T) - mct = hull.mct1cm(T) - cb = hull.block_coefficient(T) - cw = hull.waterplane_coefficient(T) - cm = hull.midship_coefficient(T) + from arshipdesign.hydrostatics.upright import compute_upright + T = hull.draft + h = compute_upright(hull, T) self._hydro.update_values({ "T": f"{T:.2f}", - "Δ": f"{delta:.1f} t", - "LCB": f"{lcb_v:.2f}", - "KB": f"{kb:.2f}", - "KMT": f"{kmt:.2f}", - "GMT": "—", # requiere KG del caso de carga - "TPC": f"{tpc:.3f}", - "MCT": f"{mct:.2f}", - "Cb": f"{cb:.3f}", - "Cw": f"{cw:.3f}", - "Cm": f"{cm:.3f}", + "Δ": f"{h.displacement:.1f} t", + "LCB": f"{h.lcb:.2f}", + "KB": f"{h.kb:.2f}", + "KMT": f"{h.kmt:.2f}", + "GMT": "—", + "TPC": f"{h.tpc:.3f}", + "MCT": f"{h.mct:.2f}", + "Cb": f"{h.cb:.3f}", + "Cw": f"{h.cw:.3f}", + "Cm": f"{h.cm:.3f}", }) except Exception as exc: logger.warning("Error al calcular hidrostáticos: %s", exc) @@ -1437,31 +1749,43 @@ class MainWindow(QMainWindow): # ───────────────────────────────────────────────────────── def _on_compute_hydrostatics(self) -> None: - """Calcula las curvas hidrostáticas y muestra el módulo.""" + """Lanza el cálculo de curvas hidrostáticas en un hilo secundario.""" if self._current_hull is None: from PySide6.QtWidgets import QMessageBox QMessageBox.information( self, "Sin casco", "Crea o abre un proyecto con un casco definido." ) return - try: - from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves - self.statusBar().showMessage("Calculando curvas hidrostáticas…") - QApplication.processEvents() - curves = HydrostaticCurves.compute( - self._current_hull, n_points=30, rho=1025.0 - ) - self._hydro_chart.set_curves(curves) - self._module_area.activate(ModuleArea.MOD_CURVES) - self.statusBar().showMessage( - f"Curvas hidrostáticas calculadas — {curves.hull_name} " - f"({len(curves.points)} puntos, T: " - f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)" - ) - except Exception as exc: - logger.error("Error al calcular curvas: %s", exc) - from PySide6.QtWidgets import QMessageBox - QMessageBox.critical(self, "Error al calcular", str(exc)) + # Evitar dos cálculos simultáneos + if hasattr(self, "_hydro_thread") and self._hydro_thread is not None: + if self._hydro_thread.isRunning(): + return + + self.statusBar().showMessage("Calculando curvas hidrostáticas…") + worker = _HydroWorker(self._current_hull, n_points=30, rho=1025.0) + thread = QThread(self) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_hydro_done) + worker.error.connect(lambda msg: ( + logger.error("Error curvas hidro: %s", msg), + self.statusBar().showMessage(f"Error: {msg}"), + )) + worker.finished.connect(thread.quit) + worker.error.connect(thread.quit) + self._hydro_thread = thread + self._hydro_worker = worker + thread.start() + + def _on_hydro_done(self, curves) -> None: + """Callback en hilo principal cuando el cálculo hidrostático termina.""" + self._hydro_chart.set_curves(curves) + self._module_area.activate(ModuleArea.MOD_CURVES) + self.statusBar().showMessage( + f"Curvas hidrostáticas calculadas — {curves.hull_name} " + f"({len(curves.points)} puntos, T: " + f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)" + ) def _on_show_hydrostatics(self) -> None: """Muestra el módulo de curvas (sin recalcular si ya hay datos).""" @@ -1494,39 +1818,59 @@ class MainWindow(QMainWindow): except Exception as exc: logger.error("Error al exportar CSV: %s", exc) from PySide6.QtWidgets import QMessageBox - QMessageBox.critical(self, "Error al exportar", str(exc)) + QMessageBox.critical(self, "Error al exportar", + "No se pudo exportar el CSV. Consulte el log para más detalles.") # ───────────────────────────────────────────────────────── # CURVA GZ — ESTABILIDAD # ───────────────────────────────────────────────────────── def _compute_and_show_gz(self) -> None: - """Calcula la curva GZ wall-sided y actualiza el widget de estabilidad.""" - if self._current_hull is None: + """Lanza el cálculo GZ en un hilo separado para no bloquear la UI.""" + if self._current_hull is None or self._gz_widget is None: return + # Evitar lanzar dos cálculos simultáneos + if hasattr(self, "_gz_thread") and self._gz_thread is not None: + if self._gz_thread.isRunning(): + return + + hull = self._current_hull + kg = hull.depth * 0.55 + self.statusBar().showMessage("Calculando curva GZ…") + + self._gz_worker = _GZWorker(hull, hull.draft, kg) + self._gz_thread = QThread(self) + self._gz_worker.moveToThread(self._gz_thread) + + self._gz_thread.started.connect(self._gz_worker.run) + self._gz_worker.finished.connect(self._on_gz_done) + self._gz_worker.error.connect( + lambda msg: ( + logger.warning("Error GZ: %s", msg), + self.statusBar().showMessage(f"Error GZ: {msg}"), + ) + ) + self._gz_worker.finished.connect(self._gz_thread.quit) + self._gz_worker.error.connect(self._gz_thread.quit) + self._gz_thread.start() + + def _on_gz_done(self, gz_curve, imo_result) -> None: + """Callback en hilo principal cuando el cálculo GZ termina.""" if self._gz_widget is None: return - try: - hull = self._current_hull - kg = hull.depth * 0.55 - self.statusBar().showMessage("Calculando curva GZ…") - QApplication.processEvents() - gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg) - imo_result = check_imo_is2008(gz_curve) - self._gz_widget.set_curve(gz_curve, imo_result) - # Actualizar indicador IMO en la barra de hidrostáticos - self._hydro.set_imo_status( - imo_result.overall_passed, - "" if imo_result.overall_passed else "GZ", - ) - self.statusBar().showMessage( - f"Curva GZ calculada — {hull.name} " - f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m " - f"AVS={gz_curve.avs:.0f}° " - f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}" - ) - except Exception as exc: - logger.warning("Error al calcular curva GZ: %s", exc) + self._gz_widget.set_curve(gz_curve, imo_result) + self._hydro.set_imo_status( + imo_result.overall_passed, + "" if imo_result.overall_passed else "GZ", + ) + hull = self._current_hull + name = hull.name if hull else "" + self.statusBar().showMessage( + f"Curva GZ calculada — {name} " + f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m " + f"AVS={gz_curve.avs:.0f}° " + f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}" + ) def _on_show_stability(self) -> None: """Muestra el módulo de estabilidad GZ (calcula si hay casco disponible).""" @@ -1607,7 +1951,9 @@ class MainWindow(QMainWindow): add_recent_file(path) self._on_project_loaded() except Exception as e: - QMessageBox.critical(self, "Error", str(e)) + logger.error("Error al abrir archivo reciente '%s': %s", path, e) + QMessageBox.critical(self, "Error", + "No se pudo abrir el archivo reciente. Consulte el log para más detalles.") # ───────────────────────────────────────────────────────── # GEOMETRÍA DE VENTANA diff --git a/arshipdesign/ui/widgets/offsets_editor.py b/arshipdesign/ui/widgets/offsets_editor.py index fc453a4..8f30b8a 100644 --- a/arshipdesign/ui/widgets/offsets_editor.py +++ b/arshipdesign/ui/widgets/offsets_editor.py @@ -370,7 +370,9 @@ class OffsetsEditor(QWidget): Path(path).write_text(buf.getvalue(), encoding="utf-8") logger.info("Offsets exportados a %s", path) except Exception as exc: - QMessageBox.critical(self, "Error al exportar", str(exc)) + logger.error("Error al exportar offsets: %s", exc) + QMessageBox.critical(self, "Error al exportar", + "No se pudieron exportar los offsets. Consulte el log para más detalles.") def _on_import_csv(self) -> None: path, _ = QFileDialog.getOpenFileName( @@ -424,7 +426,9 @@ class OffsetsEditor(QWidget): self.hull_changed.emit(new_hull) logger.info("Offsets importados desde %s", path) except Exception as exc: - QMessageBox.critical(self, "Error al importar", str(exc)) + logger.error("Error al importar offsets: %s", exc) + QMessageBox.critical(self, "Error al importar", + "No se pudieron importar los offsets. Consulte el log para más detalles.") # ---------------------------------------------------------------------- # Info label diff --git a/arshipdesign/ui/widgets/viewer_3d.py b/arshipdesign/ui/widgets/viewer_3d.py index a025bb4..07810da 100644 --- a/arshipdesign/ui/widgets/viewer_3d.py +++ b/arshipdesign/ui/widgets/viewer_3d.py @@ -18,7 +18,7 @@ from typing import Optional import numpy as np from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget logger = logging.getLogger("ui.viewer_3d") @@ -57,6 +57,9 @@ class Viewer3DWidget(QWidget): self._plotter: Optional["QtInteractor"] = None self._ready = False self._pending_hull = None # hull recibido antes de que el plotter esté listo + self._hull_actor = None # actor VTK del casco — para toggle de aristas + self._show_edges = False # mallas apagadas por defecto (como Delftship) + self._edge_btn: Optional[QPushButton] = None self._build_ui() # ------------------------------------------------------------------ @@ -96,7 +99,26 @@ class Viewer3DWidget(QWidget): old.hide() old.deleteLater() - # Crear el interactor PyVista embebido + # ── Barra de herramientas 3D ─────────────────────────────── + toolbar = QWidget() + toolbar.setObjectName("viewer3dToolbar") + tb_lo = QHBoxLayout(toolbar) + tb_lo.setContentsMargins(4, 2, 4, 2) + tb_lo.setSpacing(4) + + self._edge_btn = QPushButton("⬡ Mallas") + self._edge_btn.setCheckable(True) + self._edge_btn.setChecked(self._show_edges) + self._edge_btn.setFixedHeight(22) + self._edge_btn.setToolTip( + "Mostrar / ocultar aristas de la malla (como Delftship)" + ) + self._edge_btn.toggled.connect(self._on_edge_toggled) + tb_lo.addWidget(self._edge_btn) + tb_lo.addStretch() + lo.addWidget(toolbar) + + # ── Interactor PyVista embebido ──────────────────────────── self._plotter = QtInteractor( parent=self, auto_update=False, # sin polling continuo de GPU @@ -192,15 +214,22 @@ class Viewer3DWidget(QWidget): return self._plotter.clear() - # Casco principal — color acero naval - self._plotter.add_mesh( + # Casco principal — color sólido estilo DelftShip + # smooth_shading=False → facetas planas, aspecto sólido sin blur + # ambient alto → menos sombras duras, color uniforme + # specular bajo → sin brillos que difuminen el color + self._hull_actor = self._plotter.add_mesh( mesh, - color="#3a6080", - smooth_shading=True, - show_edges=True, - edge_color="#4da8ff", - line_width=0.3, - opacity=0.92, + color="#4a8ab0", # azul acero más vivo + smooth_shading=False, # facetado / sólido (no blur) + show_edges=self._show_edges, + edge_color="#90c8f0", + line_width=0.6, + opacity=1.0, # totalmente opaco + ambient=0.40, # luz ambiente alta: sombras suaves + diffuse=0.60, # difuso moderado + specular=0.05, # casi sin especular (anti-blur) + specular_power=5, name="hull", ) @@ -226,6 +255,17 @@ class Viewer3DWidget(QWidget): self._plotter.view_isometric() self._plotter.reset_camera() + def _on_edge_toggled(self, checked: bool) -> None: + """Activa / desactiva la malla de aristas sin re-renderizar el casco.""" + self._show_edges = checked + if self._hull_actor is not None and self._plotter is not None: + prop = self._hull_actor.GetProperty() + if checked: + prop.EdgeVisibilityOn() + else: + prop.EdgeVisibilityOff() + self._plotter.render() + def closeEvent(self, event) -> None: # type: ignore[override] """Libera el contexto PyVista al cerrar.""" if self._plotter is not None: diff --git a/arshipdesign/ui/widgets/viewer_lines.py b/arshipdesign/ui/widgets/viewer_lines.py index 3a872d2..7ec2385 100644 --- a/arshipdesign/ui/widgets/viewer_lines.py +++ b/arshipdesign/ui/widgets/viewer_lines.py @@ -27,9 +27,12 @@ from typing import Optional import numpy as np from PySide6.QtCore import QPointF, QRectF, Qt, Signal from PySide6.QtGui import ( - QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent, + QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QPolygonF, QWheelEvent, +) +from PySide6.QtWidgets import ( + QCheckBox, QFrame, QGridLayout, QHBoxLayout, QLabel, + QLineEdit, QVBoxLayout, QWidget, ) -from PySide6.QtWidgets import QWidget from arshipdesign.core.hull import Hull @@ -48,8 +51,8 @@ _AXIS = QColor("#3e4255") # Los nodos SON los vértices del polígono de control; la curva del casco # pasa CERCA de ellos (interpolante aquí, aproximante en NURBS clásico). # El gris neutro evita confusión con las curvas de casco (verde/ámbar/azul). -_CNET_TRAN = QColor(110, 120, 140, 90) # aristas (dirección estación) -_CNET_LONG = QColor(100, 112, 132, 75) # aristas (dirección LdA) +_CNET_TRAN = QColor(130, 145, 170, 160) # aristas (dirección estación) +_CNET_LONG = QColor(120, 135, 160, 145) # aristas (dirección LdA) # ── Curvas del casco (sobre la malla) ────────────────────────────────── _WATERLINE = QColor("#2878C8") # líneas de agua — azul @@ -64,12 +67,141 @@ _TEXT = QColor("#7a8ba8") # ── Nodos (vértices del polígono de control) — gris-azulado ──────────── # Gris claro = convenio DELFTship/Maxsurf para puntos de control. # Pequeño (3 px) para no tapar las curvas del casco. -_NODE_NORMAL = QColor("#A8B8D0") # gris-azulado: reposo -_NODE_HOVER = QColor("#E0EAFF") # casi blanco: hover -_NODE_DRAG = QColor("#FF3838") # rojo: arrastrando -_NODE_R = 3.0 # px semi-lado (era 4.5) -_CPT_HIT = 16.0 # px umbral de captura (alias legacy) -_CPT_RADIUS = _NODE_R # alias legacy +_BUTTOCK = QColor("#28B8A0") # teal: pantocazas (distinto de waterlines) +_NODE_NORMAL = QColor("#A8B8D0") # gris-azulado: reposo +_NODE_HOVER = QColor("#E0EAFF") # casi blanco: hover +_NODE_DRAG = QColor("#FF3838") # rojo: arrastrando +_NODE_SELECTED = QColor("#FFD700") # oro: nodo seleccionado (panel info) +_NODE_CORNER = QColor("#FF8C00") # naranja oscuro: esquina +_NODE_PEER = QColor("#00D8FF") # cian: nodo par en otra vista +_NODE_R = 3.0 # px semi-lado +_CPT_HIT = 16.0 # px umbral de captura (alias legacy) +_CPT_RADIUS = _NODE_R # alias legacy + +# Sentinels para tipos de nodo especiales (j negativo → no es índice de LdA) +_KEEL_IDX = -1 # nodo de quilla (keel_z[i] per-estación) +_SHEER_IDX = -2 # nodo de cubierta (sheer_z[i] per-estación) +_STEM_IDX = -10 # punto de control de roda; i = índice en stem_ctrl +_TRANS_IDX = -20 # punto de control de espejo; i = índice en transom_ctrl + +# Colores de los contornos especiales del perfil +_STEM_COLOR = QColor("#e03030") # rojo — roda +_TRANSOM_COLOR = QColor("#c8a000") # ámbar — contorno del espejo + +# ───────────────────────────────────────────────────────────────────────────── +# Panel flotante de información de nodo +# ───────────────────────────────────────────────────────────────────────────── + +class NodeInfoPanel(QFrame): + """Panel flotante con coordenadas X/Y/Z editables y checkbox de esquina. + + Los campos son QLineEdit: el usuario puede escribir un valor y pulsar + Enter para aplicarlo directamente al nodo seleccionado. + + Se posiciona en la esquina superior-derecha del visor padre. + """ + + corner_toggled = Signal(bool) # checkbox cambió + coord_edited = Signal(str, float) # ("x"/"y"/"z", nuevo_valor_mundo) + + _EDIT_SS = ( + "QLineEdit { background: rgba(10,18,32,200); border: 1px solid #2a3a5a;" + "border-radius: 3px; color: #d0e8ff; font-family: Consolas; font-size: 10px;" + "padding: 1px 4px; }" + "QLineEdit:focus { border: 1px solid #4a7aaa; }" + ) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("NodeInfoPanel") + # Evitar que clics en etiquetas/fondo del panel propaguen al viewer + # y limpien _selected_idx accidentalmente. + self.setAttribute(Qt.WidgetAttribute.WA_NoMousePropagation, True) + self.setStyleSheet( + "NodeInfoPanel { background: rgba(15,22,38,230); border: 1px solid #3a4a6a;" + "border-radius: 6px; }" + "QLabel { color: #6080a0; font-family: Consolas; font-size: 9px; }" + "QCheckBox { color: #c8dff0; font-size: 10px; }" + + self._EDIT_SS + ) + self.setFixedWidth(188) + + outer = QVBoxLayout(self) + outer.setContentsMargins(8, 6, 8, 6) + outer.setSpacing(4) + + # Título + title = QLabel("Nodo seleccionado") + title.setStyleSheet("color:#5878a0; font-size:9px;") + outer.addWidget(title) + + # Rejilla X / Y / Z — etiqueta + QLineEdit editable + grid = QGridLayout() + grid.setSpacing(3) + grid.setContentsMargins(0, 0, 0, 0) + + self._edits: dict[str, QLineEdit] = {} + for row, axis in enumerate(("x", "y", "z")): + lbl = QLabel(axis.upper() + ":") + lbl.setFixedWidth(14) + edit = QLineEdit("—") + edit.setFixedHeight(18) + edit.setAlignment(Qt.AlignmentFlag.AlignRight) + edit.returnPressed.connect(lambda a=axis: self._on_return(a)) + grid.addWidget(lbl, row, 0) + grid.addWidget(edit, row, 1) + self._edits[axis] = edit + + outer.addLayout(grid) + + # Hint — Intro para aplicar + hint = QLabel("↵ Enter para aplicar") + hint.setStyleSheet("color:#405060; font-size:8px;") + outer.addWidget(hint) + + # Checkbox esquina + self._chk_corner = QCheckBox("Esquina (sharp)") + self._chk_corner.toggled.connect(self._on_toggle) + outer.addWidget(self._chk_corner) + + self.hide() + + # ── API pública ─────────────────────────────────────────────────────── + + def update_node(self, x: float, y: float, z: float, is_corner: bool) -> None: + """Actualiza los valores mostrados y el estado del checkbox.""" + for axis, val in (("x", x), ("y", y), ("z", z)): + edit = self._edits[axis] + # Solo sobreescribir si el campo no tiene foco (el usuario podría estar editando) + if not edit.hasFocus(): + edit.setText(f"{val:+.4f}") + self._chk_corner.blockSignals(True) + self._chk_corner.setChecked(is_corner) + self._chk_corner.blockSignals(False) + self.adjustSize() + self._reposition() + self.show() + self.raise_() + + def _reposition(self) -> None: + parent = self.parentWidget() + if parent is None: + return + self.move(parent.width() - self.width() - 8, 8) + + # ── Handlers internos ──────────────────────────────────────────────── + + def _on_return(self, axis: str) -> None: + text = self._edits[axis].text().strip() + try: + value = float(text) + except ValueError: + return # texto inválido → ignorar + self.coord_edited.emit(axis, value) + + def _on_toggle(self, checked: bool) -> None: + self.corner_toggled.emit(checked) + # ───────────────────────────────────────────────────────────────────────────── # Clase base @@ -82,6 +214,10 @@ class _BaseViewer(QWidget): offsets_dragging = Signal(object) # OffsetsTable — actualización en vivo # Emitido cuando el usuario suelta el botón (fin del drag) offsets_edited = Signal(object) # OffsetsTable modificada + # Emitido cuando cambia el nodo seleccionado (None = deselección) + node_selected = Signal(object) # Optional[tuple[int, int]] + # Emitido cuando el usuario cambia el zoom (wheel) — escala px/m + scale_changed = Signal(float) def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) @@ -91,11 +227,23 @@ class _BaseViewer(QWidget): self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho) # Estado de edición de puntos de control - self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline) - self._drag_idx: Optional[tuple[int, int]] = None - self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) + self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline) + self._drag_idx: Optional[tuple[int, int]] = None + self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) + self._selected_idx: Optional[tuple[int, int]] = None # nodo seleccionado (panel info) + self._peer_selected_idx: Optional[tuple[int, int]] = None # seleccionado en otra vista + # Curva de control seleccionada con Shift+clic (arista de la malla NURBS) + # ("wl", j) → línea de agua j | ("sta", i) → estación i + # ("keel", None) → quilla | ("sheer", None) → cubierta + self._selected_curve: Optional[tuple[str, Optional[int]]] = None - self._show_curvature = False # toggle con tecla C + # Panel flotante de información de nodo (corner + coords editables) + self._info_panel = NodeInfoPanel(self) + self._info_panel.corner_toggled.connect(self._on_corner_toggled) + self._info_panel.coord_edited.connect(self._on_coord_edited) + + self._show_curvature = False # toggle con tecla [C] + self._show_fairness = False # toggle con tecla [F] — coloreo de equidad self.setMouseTracking(True) self.setCursor(Qt.CursorShape.ArrowCursor) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) @@ -130,33 +278,148 @@ class _BaseViewer(QWidget): (sy - self._offset.y()) / self._scale, ) - def _fit_to_view(self) -> None: - if self._hull is None: - return + def fit_scale(self) -> float: + """Escala px/m que haría caber el contenido en este widget.""" + bbox = self._world_bbox() + if bbox is None: + return 1.0 + wx0, wy0, wx1, wy1 = bbox + ww, wh = wx1 - wx0, wy1 - wy0 + if ww < 1e-6 or wh < 1e-6: + return 1.0 + pw, ph = max(self.width(), 100), max(self.height(), 100) + m = 0.08 + return min(pw * (1 - m * 2) / ww, ph * (1 - m * 2) / wh) + + def center_at_scale(self, scale: float) -> None: + """Aplica la escala dada y centra el contenido en el widget.""" bbox = self._world_bbox() if bbox is None: return wx0, wy0, wx1, wy1 = bbox ww, wh = wx1 - wx0, wy1 - wy0 - if ww < 1e-6 or wh < 1e-6: - return pw, ph = max(self.width(), 100), max(self.height(), 100) - margin = 0.08 - self._scale = min( - pw * (1 - margin * 2) / ww, - ph * (1 - margin * 2) / wh, + self._scale = scale + self._offset = QPointF( + pw / 2 - (wx0 + ww / 2) * scale, + ph / 2 - (wy0 + wh / 2) * scale, ) - cx = pw / 2 - (wx0 + ww / 2) * self._scale - cy = ph / 2 - (wy0 + wh / 2) * self._scale - self._offset = QPointF(cx, cy) + self.update() + + def _fit_to_view(self) -> None: + if self._hull is None: + return + self.center_at_scale(self.fit_scale()) def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key.Key_C: + key = event.key() + if key == Qt.Key.Key_C: self._show_curvature = not self._show_curvature self.update() + elif key == Qt.Key.Key_F: + self._show_fairness = not self._show_fairness + self.update() + elif key == Qt.Key.Key_S: + if self._smooth_selected_node(): + if self._hull is not None: + self._hull.invalidate() + self.offsets_edited.emit( + self._hull.offsets if self._hull is not None else None) + self.update() else: super().keyPressEvent(event) + def _smooth_selected_node(self) -> bool: + """Aplica 1 paso Laplaciano local al nodo seleccionado. + + Suaviza solo el nodo activo promediando con sus vecinos estación anterior + y posterior. Para nodos de quilla/cubierta suaviza la componente Z. + Retorna True si realizó el suavizado. + """ + if self._selected_idx is None or self._hull is None: + return False + i, j = self._selected_idx + ot = self._hull.offsets + # Nodo de datos interior + if j >= 0 and 0 < i < ot.n_stations - 1: + prev_y = float(ot.data[i - 1, j]) + cur_y = float(ot.data[i, j]) + next_y = float(ot.data[i + 1, j]) + ot.data[i, j] = max(0.0, (prev_y + cur_y + next_y) / 3.0) + return True + # Nodo de quilla interior + if j == _KEEL_IDX and 0 < i < ot.n_stations - 1: + kz = ot.keel_z + kz[i] = (float(kz[i - 1]) + float(kz[i]) + float(kz[i + 1])) / 3.0 + return True + # Nodo de cubierta interior + if j == _SHEER_IDX and 0 < i < ot.n_stations - 1: + if len(self._hull.sheer_z) != ot.n_stations: + self._hull.sheer_z = self._hull.get_sheer_z().copy() + sz = self._hull.sheer_z + sz[i] = (float(sz[i - 1]) + float(sz[i]) + float(sz[i + 1])) / 3.0 + return True + return False + + def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]: + """Detecta la arista de la malla de control más cercana a pos (Shift+clic). + + Retorna ("wl", j) para línea de agua j, + ("sta", i) para estación i, o None si no hay nada cercano. + Las subclases sobreescriben para añadir aristas especiales (keel, sheer). + """ + if self._hull is None: + return None + ot = self._hull.offsets + n_sta, n_wl = ot.n_stations, ot.n_waterlines + THRESHOLD = _CPT_HIT * 2.0 + best_d, result = THRESHOLD, None + + for j in range(n_wl): + for i in range(n_sta - 1): + d = _dist_to_segment(pos, self._screen_pt(i, j), self._screen_pt(i + 1, j)) + if d < best_d: + best_d, result = d, ("wl", j) + + for i in range(n_sta): + for j in range(n_wl - 1): + d = _dist_to_segment(pos, self._screen_pt(i, j), self._screen_pt(i, j + 1)) + if d < best_d: + best_d, result = d, ("sta", i) + + return result + + def _fairness_color(self, i: int, j: int) -> QColor: + """Color del nodo (i, j) según su segunda derivada en dirección longitudinal. + + Verde → suave (equidad alta), Amarillo → moderado, Rojo → quiebre brusco. + Solo aplica a nodos interiores de la tabla de offsets. + """ + if self._hull is None: + return _NODE_NORMAL + ot = self._hull.offsets + if not (0 < i < ot.n_stations - 1) or not (0 <= j < ot.n_waterlines): + return _NODE_NORMAL + xs = ot.x_stations + dx = float(xs[i + 1] - xs[i - 1]) * 0.5 + if dx < 1e-9: + return _NODE_NORMAL + y = ot.data + d2 = abs(float(y[i + 1, j]) - 2.0 * float(y[i, j]) + float(y[i - 1, j])) + roughness = d2 / (dx * dx) # m⁻¹ (segunda derivada normalizada) + # Umbrales empíricos — 0.005 = muy suave, 0.15 = quiebre visible + t_lo, t_hi = 0.005, 0.15 + if roughness <= t_lo: + return QColor("#22cc66") # verde + if roughness >= t_hi: + return QColor("#e03030") # rojo + t = (roughness - t_lo) / (t_hi - t_lo) + if t < 0.5: + t2 = t * 2.0 + return QColor(int(34 + 221 * t2), int(204), int(int(102 * (1 - t2)))) + t2 = (t - 0.5) * 2.0 + return QColor(255, int(204 * (1 - t2) + 48 * t2), 0) + def _world_bbox(self) -> Optional[tuple[float, float, float, float]]: return None # subclases @@ -177,19 +440,43 @@ class _BaseViewer(QWidget): pos.y() + (self._offset.y() - pos.y()) * factor, ) self._scale *= factor + self.scale_changed.emit(self._scale) self.update() def mousePressEvent(self, event) -> None: self.setFocus() # captura el foco de teclado al hacer clic - btn = event.button() + btn = event.button() + mods = event.modifiers() + if btn == Qt.MouseButton.LeftButton and self._hull is not None: + # ── Shift+clic: selección de curva completa (estilo Delftship) ── + if mods & Qt.KeyboardModifier.ShiftModifier: + curve = self._hit_test_edge(event.position()) + if curve != self._selected_curve: + self._selected_curve = curve + self.update() + event.accept() + return + + # ── Clic normal: arrastre de nodo (limpia selección de curva) ─── + self._selected_curve = None idx = self._hit_test(event.position()) if idx is not None: self._drag_idx = idx - self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]]) + # j < 0 → nodo especial (keel/sheer), no está en data[i,j] + if idx[1] >= 0: + self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]]) + else: + self._drag_orig = 0.0 self.setCursor(Qt.CursorShape.SizeAllCursor) event.accept() return + else: + # Clic en espacio vacío → deseleccionar nodo actual + if self._selected_idx is not None: + self._selected_idx = None + self._info_panel.hide() + self.update() if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._pan_start = event.position() @@ -224,10 +511,12 @@ class _BaseViewer(QWidget): def mouseReleaseEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None: + self._selected_idx = self._drag_idx # seleccionar nodo al soltar self._drag_idx = None self.setCursor(Qt.CursorShape.ArrowCursor) if self._hull is not None: self.offsets_edited.emit(self._hull.offsets) + self._on_node_selected(self._selected_idx) event.accept() return if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): @@ -237,6 +526,137 @@ class _BaseViewer(QWidget): self._fit_to_view() self.update() + # ─── Métodos de selección y panel de información ───────────────────────── + + def _node_world_xyz(self, idx: tuple[int, int]) -> tuple[float, float, float]: + """Devuelve (x, y, z) en coordenadas de buque del nodo (i, j).""" + if self._hull is None: + return 0.0, 0.0, 0.0 + i, j = idx + ot = self._hull.offsets + if j == _KEEL_IDX: + x = float(ot.x_stations[i]) + float(self._hull.get_keel_x_offsets()[i]) + y = 0.0 + z = float(ot.keel_z[i]) + elif j == _SHEER_IDX: + x = float(ot.x_stations[i]) + float(self._hull.get_sheer_x_offsets()[i]) + y = float(ot.data[i, -1]) if ot.n_waterlines > 0 else 0.0 + z = float(self._hull.get_sheer_z()[i]) + elif j == _STEM_IDX: + c = self._hull.get_stem_ctrl() + x, z = float(c[i, 0]), float(c[i, 1]) + y = 0.0 + elif j == _TRANS_IDX: + c = self._hull.get_transom_ctrl() + x, z = float(c[i, 0]), float(c[i, 1]) + y = 0.0 + else: + x = float(ot.x_stations[i]) + float(ot.x_offsets[i, j]) + y = float(ot.data[i, j]) + z = float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j]) + return x, y, z + + def _on_node_selected(self, idx: Optional[tuple[int, int]]) -> None: + """Muestra el panel de información para el nodo seleccionado.""" + self.node_selected.emit(idx) + if idx is None or self._hull is None: + self._info_panel.hide() + return + x, y, z = self._node_world_xyz(idx) + is_c = (idx[1] not in (_STEM_IDX, _TRANS_IDX) and + self._hull.is_corner(idx[0], idx[1])) + self._info_panel.update_node(x, y, z, is_c) + + def set_peer_selection(self, idx: Optional[tuple[int, int]]) -> None: + """Resalta el nodo (i, j) seleccionado en otra vista con anillo cian.""" + if idx != self._peer_selected_idx: + self._peer_selected_idx = idx + self.update() + + def _on_corner_toggled(self, checked: bool) -> None: + """Aplica el cambio de esquina al hull y redibuja.""" + if self._selected_idx is None or self._hull is None: + return + i, j = self._selected_idx + if checked != self._hull.is_corner(i, j): + self._hull.toggle_corner(i, j) + self.offsets_edited.emit(self._hull.offsets) + self.update() + # Refrescar panel para confirmar estado real (gold diamond ahora visible) + self._on_node_selected(self._selected_idx) + + def _on_coord_edited(self, axis: str, value: float) -> None: + """Aplica el valor tecleado en el panel de info al nodo seleccionado. + + Hace la transformación inversa de _node_world_xyz: + valor mundo → offset almacenado en hull.offsets o hull.keel/sheer arrays. + """ + if self._selected_idx is None or self._hull is None: + return + try: + self._apply_coord_edit(axis, value) + except Exception as exc: # noqa: BLE001 + import traceback + traceback.print_exc() + return + + def _apply_coord_edit(self, axis: str, value: float) -> None: + """Implementación real de _on_coord_edited (separada para capturar excepciones).""" + i, j = self._selected_idx + ot = self._hull.offsets + + if j == _KEEL_IDX: + if axis == "x": + kxo = self._hull.get_keel_x_offsets().copy() + kxo[i] = value - float(ot.x_stations[i]) + self._hull.keel_x_offsets = kxo + elif axis == "z": + ot.keel_z[i] = value + # y siempre 0 en quilla — ignorar + + elif j == _SHEER_IDX: + if axis == "x": + sxo = self._hull.get_sheer_x_offsets().copy() + sxo[i] = value - float(ot.x_stations[i]) + self._hull.sheer_x_offsets = sxo + elif axis == "y": + if ot.n_waterlines > 0: + ot.data[i, -1] = max(0.0, value) + elif axis == "z": + # Inicializar sheer_z si estaba vacío (default antes del arrufo) + if len(self._hull.sheer_z) != ot.n_stations: + self._hull.sheer_z = self._hull.get_sheer_z().copy() + self._hull.sheer_z[i] = value + + elif j == _STEM_IDX: + sc = self._hull.stem_ctrl + if sc.ndim == 2 and i < sc.shape[0]: + if axis == "x": + sc[i, 0] = value + elif axis == "z": + sc[i, 1] = value + + elif j == _TRANS_IDX: + tc = self._hull.transom_ctrl + if tc.ndim == 2 and i < tc.shape[0]: + if axis == "x": + tc[i, 0] = value + elif axis == "z": + tc[i, 1] = value + + else: # nodo de línea de agua regular + if axis == "x": + ot.x_offsets[i, j] = value - float(ot.x_stations[i]) + elif axis == "y": + ot.data[i, j] = max(0.0, value) + elif axis == "z": + ot.z_offsets[i, j] = value - float(ot.z_waterlines[j]) + + self._hull.invalidate() + self.offsets_edited.emit(self._hull.offsets) + self._on_node_selected(self._selected_idx) # refresca panel con valor calculado real + self.update() + # ─── Métodos de edición (implementados por subclases) ──────────────────── def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: @@ -267,13 +687,35 @@ class _BaseViewer(QWidget): p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) def _draw_hint_overlay(self, p: QPainter) -> None: - """Esquina inferior-derecha: atajo de teclado para curvatura.""" - txt = "[C] Curvatura ON" if self._show_curvature else "[C] Curvatura" - col = QColor("#ffd700") if self._show_curvature else QColor("#3a4870") - p.setFont(QFont("Monospace", 7)) - p.setPen(QPen(col)) - r = self.rect().adjusted(0, 0, -4, -4) - p.drawText(r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, txt) + """Esquina inferior-derecha: atajos de teclado activos.""" + p.setFont(QFont("Consolas", 8)) + r = self.rect().adjusted(0, 0, -6, -6) + curve_label = "" + if self._selected_curve is not None: + ctype, cidx = self._selected_curve + curve_label = { + "keel": "curva: QUILLA", + "sheer": "curva: CUBIERTA", + }.get(ctype, f"curva: {'LdA' if ctype == 'wl' else 'STA'} {cidx}") + lines = [ + ("[Shift+clic] Seleccionar curva", bool(self._selected_curve)), + ("[C] Curvatura", self._show_curvature), + ("[F] Equidad", self._show_fairness), + ("[S] Suavizar nodo", False), + ] + y_off = 0 + if curve_label: + p.setPen(QPen(QColor("#00FFB0"))) + adj_r = r.adjusted(0, 0, 0, -y_off) + p.drawText(adj_r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, curve_label) + y_off += 11 + for txt, active in reversed(lines): + label = f"{txt} ON" if active else txt + col = QColor("#ffd700") if active else QColor("#6878a8") + p.setPen(QPen(col)) + adj_r = r.adjusted(0, 0, 0, -y_off) + p.drawText(adj_r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, label) + y_off += 11 def _draw_control_point( self, @@ -287,19 +729,50 @@ class _BaseViewer(QWidget): casco (azul/verde/dorado). La forma cuadrada evoca el vocabulario de las herramientas CAD (Maxsurf, DelftShip). """ + is_corner = (self._hull is not None and + idx[1] not in (_STEM_IDX, _TRANS_IDX) and + self._hull.is_corner(idx[0], idx[1])) if idx == self._drag_idx: color = _NODE_DRAG r = _NODE_R * 1.8 + elif idx == self._selected_idx: + color = _NODE_SELECTED + r = _NODE_R * 1.6 elif idx == self._hover_idx: color = _NODE_HOVER r = _NODE_R * 1.4 + elif is_corner: + color = _NODE_CORNER + r = _NODE_R * 1.2 + elif self._show_fairness and idx[1] >= 0: + # Coloreo de equidad: verde=suave → rojo=quiebre + color = self._fairness_color(idx[0], idx[1]) + r = _NODE_R * 1.1 else: color = _NODE_NORMAL r = _NODE_R - from PySide6.QtCore import QRectF p.setPen(QPen(color.darker(180), 1)) p.setBrush(QBrush(color)) - p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2)) + # Esquinas → rombo (45°) — también cuando están seleccionadas + # para que el usuario vea inmediatamente que la esquina fue marcada. + # Solo el modo drag fuerza cuadrado (para no confundir durante arrastre). + if is_corner and idx != self._drag_idx: + cx, cy = screen_pt.x(), screen_pt.y() + diamond = QPolygonF([ + QPointF(cx, cy - r), + QPointF(cx + r, cy ), + QPointF(cx, cy + r), + QPointF(cx - r, cy ), + ]) + p.drawPolygon(diamond) + else: + p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2)) + # Anillo cian: nodo correspondiente seleccionado en otra vista + if idx == self._peer_selected_idx: + rp = _NODE_R * 2.8 + p.setPen(QPen(_NODE_PEER, 1.8)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(screen_pt, rp, rp) # ───────────────────────────────────────────────────────────────────────────── @@ -327,13 +800,13 @@ def _draw_cnet_bodyplan(p: QPainter, ot, w2s_fn) -> None: 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]) + pt = w2s_fn(sign * ot.data[i, k], float(ot.z_waterlines[k]) + float(ot.z_offsets[i, 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)) + # Sin cierre al eje: el polígono de control es abierto (quilla→cubierta). + # El cierre recto a (0,0) solo se dibuja en la curva del casco (Capa 3). p.drawPath(path) @@ -361,7 +834,8 @@ def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None: for i in range(n_sta): path = QPainterPath() for j in range(n_wl): - pt = w2s_fn(ot.x_stations[i], sign * ot.data[i, j]) + x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j]) + pt = w2s_fn(x_eff, sign * ot.data[i, j]) if j == 0: path.moveTo(pt) else: @@ -372,7 +846,8 @@ def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None: for j in range(n_wl): path = QPainterPath() for i in range(n_sta): - pt = w2s_fn(ot.x_stations[i], sign * ot.data[i, j]) + x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j]) + pt = w2s_fn(x_eff, sign * ot.data[i, j]) if i == 0: path.moveTo(pt) else: @@ -381,42 +856,99 @@ def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None: def _compute_buttock_pts( - ot, y_b: float + ot, + y_b: float, + keel_z: Optional[np.ndarray] = None, + sheer_z: Optional[np.ndarray] = None, + keel_x_off: Optional[np.ndarray] = None, + sheer_x_off: Optional[np.ndarray] = None, ) -> list[tuple[float, float]]: - """Calcula los puntos (x_est, z) de una línea de pantoque (buttock) a semi-manga y_b. + """Calcula los puntos (x_eff, z) de una línea de pantoque a semi-manga y_b. - Una línea de pantoque es la intersección del casco con el plano vertical - paralelo al plano de crujía a distancia y_b del eje (Y = cte). - - Para cada estación i, se busca la altura z a la que la semi-manga del - casco iguala y_b, interpolando linealmente entre líneas de agua adyacentes. - El resultado se dibuja en la vista de perfil como una curva (x, z). + La pantoque se extiende hasta la cubierta (sheer) en sus extremos, tal + como se representa en planos de líneas tradicionales (Rawson & Tupper §1). Parámetros ---------- ot : OffsetsTable y_b : float Semi-manga de la pantoque [m]. - - Retorna - ------- - Lista de tuplas (x_station, z_interp) ordenadas de AP a FP. + keel_z : array (n_sta,) | None + Altura de quilla por estación. Si None se asume z=0 en todas. + sheer_z : array (n_sta,) | None + Altura de cubierta por estación. Si None se usa el último z_waterlines. + keel_x_off : array (n_sta,) | None + Desviación X del nodo de quilla por estación [m]. Si None → 0. + sheer_x_off : array (n_sta,) | None + Desviación X del nodo de cubierta por estación [m]. Si None → 0. + Necesario para que un transom invertido/raked desplace correctamente + el extremo de cada pantoque hacia la popa. """ pts: list[tuple[float, float]] = [] + # (i, sz_i, z_interp, sx_i) para añadir extremos hasta cubierta al final + valid_info: list[tuple[int, float, float, float]] = [] + z_wl_top = float(ot.z_waterlines[-1]) + for i in range(ot.n_stations): - hb = ot.data[i, :] # semi-mangas en cada LdA para estación i - zz = ot.z_waterlines + x_nom = float(ot.x_stations[i]) + hb_base = ot.data[i, :] + zz_base = ot.z_waterlines + ot.z_offsets[i, :] + xx_base = ot.x_offsets[i, :] # desvío X per-nodo de línea de agua + + kz_i = float(keel_z[i]) if keel_z is not None else 0.0 + sz_i = float(sheer_z[i]) if sheer_z is not None else z_wl_top + kx_i = float(keel_x_off[i]) if keel_x_off is not None else 0.0 + sx_i = float(sheer_x_off[i]) if sheer_x_off is not None else 0.0 + + # Prepend keel point: breadth = 0 a keel_z[i], x_off = keel_x_off[i] + if kz_i < float(zz_base[0]) - 1e-6: + hb = np.concatenate([[0.0], hb_base]) + zz = np.concatenate([[kz_i], zz_base]) + xx = np.concatenate([[kx_i], xx_base]) + else: + hb = hb_base.copy() + zz = zz_base.copy() + xx = xx_base.copy() + + # Append sheer point si sheer_z[i] supera el último waterline. + # Costado vertical → breadth = data[i, -1] hasta cubierta. + if sz_i > z_wl_top + 1e-6: + hb = np.append(hb, float(ot.data[i, -1])) + zz = np.append(zz, sz_i) + xx = np.append(xx, sx_i) + if y_b > float(hb.max()): - continue # la pantoque no alcanza esta estación - # Buscar primer cruce ascendente (quilla → cubierta) + continue # pantoque demasiado ancha para esta estación + + # Buscar primer cruce ascendente (quilla → sheer) for j in range(len(hb) - 1): h0, h1 = float(hb[j]), float(hb[j + 1]) if h0 <= y_b <= h1: dh = h1 - h0 t = (y_b - h0) / dh if abs(dh) > 1e-9 else 0.0 - z_interp = float(zz[j]) + t * (float(zz[j + 1]) - float(zz[j])) - pts.append((float(ot.x_stations[i]), z_interp)) + z_interp = float(zz[j]) + t * (float(zz[j + 1]) - float(zz[j])) + # X efectiva interpolada entre los dos nodos del intervalo + x_off_int = float(xx[j]) + t * (float(xx[j + 1]) - float(xx[j])) + x_eff = x_nom + x_off_int + pts.append((x_eff, z_interp)) + valid_info.append((i, sz_i, z_interp, sx_i)) break + + # ── Extender hasta cubierta en los extremos (AP y FP) ───────────────── + # En planos de líneas, las pantocazas terminan en la línea de cubierta. + # El punto terminal usa la X efectiva del nodo sheer (incluye sheer_x_off), + # lo que refleja la inclinación del transom invertido en la popa. + if valid_info: + # Extremo de proa (FP) — último válido + i_fwd, sz_fwd, z_fwd, sx_fwd = valid_info[-1] + if sz_fwd > z_fwd + 1e-4: + pts.append((float(ot.x_stations[i_fwd]) + sx_fwd, sz_fwd)) + # Extremo de popa (AP) — primero válido + if len(valid_info) > 1: + i_aft, sz_aft, z_aft, sx_aft = valid_info[0] + if sz_aft > z_aft + 1e-4: + pts.insert(0, (float(ot.x_stations[i_aft]) + sx_aft, sz_aft)) + return pts @@ -439,10 +971,160 @@ def _smooth_pts(pts_2d: np.ndarray, n: int = 60) -> np.ndarray: return pts_2d +def _smooth_pts_cp(ctrl_2d: np.ndarray, n: int = 60) -> np.ndarray: + """B-spline APROXIMANTE desde puntos de control (comportamiento NURBS real). + + A diferencia de ``_smooth_pts`` (interpolante), la curva es *atraída* por + los puntos de control pero NO pasa necesariamente por los interiores — + solo por los extremos (knot vector clamped). + + Ventaja clave: mover un punto de control deforma SUAVEMENTE toda la curva + con influencia ponderada decreciente; no crea kinks locales. + + ctrl_2d : shape (m, 2) + Returns : shape (n, 2) + """ + from scipy.interpolate import BSpline as _SciPyBSpline + m = len(ctrl_2d) + if m < 2: + return ctrl_2d.copy() + k = min(3, m - 1) + # Knot vector clamped → extremos interpolados exactamente, interior aproximado + n_int = max(0, m - k - 1) + interior = np.linspace(0.0, 1.0, n_int + 2)[1:-1] if n_int > 0 else np.array([]) + t_knots = np.concatenate([np.zeros(k + 1), interior, np.ones(k + 1)]) + try: + spl = _SciPyBSpline(t_knots, ctrl_2d, k) + t_eval = np.linspace(0.0, 1.0, max(2, n)) + return spl(t_eval) + except Exception: + return ctrl_2d.copy() + + +def _smooth_curve_segs( + ctrl_2d: np.ndarray, + corner_mask: "list[bool]", + n: int = 60, +) -> np.ndarray: + """B-spline aproximante con soporte de nodos esquina. + + Los índices True en *corner_mask* generan ruptura de tangente: + la curva se parte en segmentos independientes con ángulo agudo. + + ctrl_2d : shape (m, 2) + corner_mask: len m booleans — True = esquina en ese índice + Returns : shape (n, 2) + """ + m = len(ctrl_2d) + if not corner_mask or not any(corner_mask[1:m - 1]): + return _smooth_pts_cp(ctrl_2d, n) + + corners = [k for k in range(1, m - 1) if corner_mask[k]] + split_pts = [0] + corners + [m - 1] + result: list = [] + for si in range(len(split_pts) - 1): + s, e = split_pts[si], split_pts[si + 1] + seg = ctrl_2d[s: e + 1] + n_seg = max(2, round(n * (e - s) / max(1, m - 1))) + pts = _smooth_pts_cp(seg, n_seg) + if result: + result.extend(pts[1:].tolist()) + else: + result.extend(pts.tolist()) + return np.array(result, dtype=float) + + # ───────────────────────────────────────────────────────────────────────────── # 1. Body Plan — secciones transversales # ───────────────────────────────────────────────────────────────────────────── +def _draw_dim_grid( + p: QPainter, + w2s_fn, + s2w_fn, + widget_w: int, + widget_h: int, +) -> None: + """Grilla cartesiana de fondo con cotas en metros. + + Dibuja líneas de referencia muy tenues con etiquetas de medidas reales. + Usa la escala actual del visor para elegir un intervalo 'bonito'. + No modifica el estado del painter más allá del pen/font. + """ + # Rango del mundo visible en las cuatro esquinas + wx0, wy0 = s2w_fn(0, widget_h) + wx1, wy1 = s2w_fn(widget_w, 0 ) + xlo, xhi = min(wx0, wx1), max(wx0, wx1) + ylo, yhi = min(wy0, wy1), max(wy0, wy1) + + def _nice(rng: float, tgt: int = 7) -> float: + if rng < 1e-9: + return 1.0 + raw = rng / tgt + mag = 10.0 ** math.floor(math.log10(max(raw, 1e-12))) + n = raw / mag + if n < 1.5: return mag + if n < 3.5: return 2.0 * mag + if n < 7.5: return 5.0 * mag + return 10.0 * mag + + sx = _nice(xhi - xlo) + sy = _nice(yhi - ylo) + + gc = QColor(60, 78, 110, 80) # líneas de grilla — tenue + gtxt = QColor(200, 220, 255, 230) # etiquetas — blanco-azulado brillante + + # Fuentes monoespaciadas disponibles en Windows; fallback a genérica + font = QFont("Consolas", 9) + font.setWeight(QFont.Weight.Medium) + p.setFont(font) + + fm_h = 12 # altura aproximada de línea de texto en px (9pt ≈ 12px a 96dpi) + + # ── Líneas verticales (X = constante) ──────────────────────────────── + x = math.floor(xlo / sx) * sx + while x <= xhi + sx * 0.5: + sx_lo = w2s_fn(x, ylo) + sx_hi = w2s_fn(x, yhi) + p.setPen(QPen(gc, 0.5)) + p.drawLine(sx_lo, sx_hi) + # Etiqueta anclada a borde inferior del widget, no al extremo de la línea + tx = sx_lo.x() + ty = widget_h - fm_h - 3 # siempre dentro del widget + label = f"{x:.0f}m" + lw = max(40, len(label) * 7) + # Fondo oscuro semiopaco para legibilidad sobre cualquier color + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(10, 14, 26, 190)) + p.drawRoundedRect(QRectF(tx - lw / 2 - 2, ty - 1, lw + 4, fm_h + 2), 2, 2) + p.setPen(QPen(gtxt)) + p.drawText(QRectF(tx - lw / 2, ty, lw, fm_h), + Qt.AlignmentFlag.AlignCenter, label) + x += sx + + # ── Líneas horizontales (Z = constante) ────────────────────────────── + y = math.floor(ylo / sy) * sy + while y <= yhi + sy * 0.5: + sy_l = w2s_fn(xlo, y) + sy_r = w2s_fn(xhi, y) + p.setPen(QPen(gc, 0.5)) + p.drawLine(sy_l, sy_r) + # Etiqueta anclada a borde izquierdo del widget + tx = 3 + ty = sy_l.y() - fm_h // 2 + ty = max(2, min(ty, widget_h - fm_h - 2)) + label = f"{y:.1f}" + lw = max(32, len(label) * 7) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(10, 14, 26, 190)) + p.drawRoundedRect(QRectF(tx - 1, ty - 1, lw + 4, fm_h + 2), 2, 2) + p.setPen(QPen(gtxt)) + p.drawText(QRectF(tx, ty, lw, fm_h), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + label) + y += sy + + class BodyPlanViewer(_BaseViewer): """Vista de cuadernas (body plan). @@ -496,40 +1178,97 @@ class BodyPlanViewer(_BaseViewer): return None ot = self._hull.offsets y_max = ot.max_half_breadth * 1.15 - z_max = ot.draft * 1.20 - return (-y_max, -z_max * 0.05, y_max, z_max) + z_max = max(float(self._hull.get_sheer_z().max()), float(ot.z_waterlines[-1])) * 1.20 + z_min = min(float(ot.keel_z.min()), 0.0) * 1.15 - z_max * 0.05 + return (-y_max, z_min, y_max, z_max) # ── Edición ─────────────────────────────────────────────────────────────── def _screen_pt(self, i: int, j: int) -> QPointF: - """Punto de control (i, j) en coordenadas de pantalla.""" + """Punto de control (i, j) en coordenadas de pantalla. + + j = _KEEL_IDX (-1): quilla per-estación en crujía. + j = _SHEER_IDX (-2): cubierta (breadth = último LdA, Z = sheer_z[i]). + j >= 0: LdA normal. + """ ot = self._hull.offsets - y = ot.data[i, j] - z = ot.z_waterlines[j] sign = 1.0 if i >= ot.n_stations // 2 else -1.0 + if j == _KEEL_IDX: + return self._w2s(0.0, float(ot.keel_z[i])) + if j == _SHEER_IDX: + y = float(ot.data[i, -1]) + return self._w2s(sign * y, float(self._hull.get_sheer_z()[i])) + y = ot.data[i, j] + z = float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j]) return self._w2s(sign * y, z) + def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]: + """En Body Plan: Shift+clic sobre una sección selecciona la estación i.""" + if self._hull is None: + return None + ot = self._hull.offsets + n_sta = ot.n_stations + THRESHOLD = _CPT_HIT * 2.0 + best_d, result = THRESHOLD, None + sentinels_and_wl = (_KEEL_IDX,) + tuple(range(ot.n_waterlines)) + (_SHEER_IDX,) + for i in range(n_sta): + pts = [sentinels_and_wl[k:k+2] for k in range(len(sentinels_and_wl) - 1)] + for ja, jb in pts: + d = _dist_to_segment(pos, self._screen_pt(i, ja), self._screen_pt(i, jb)) + if d < best_d: + best_d, result = d, ("sta", i) + return result + def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: return None ot = self._hull.offsets best_d, best_idx = _CPT_HIT, None for i in range(ot.n_stations): - for j in range(ot.n_waterlines): - d = _dist(pos, self._screen_pt(i, j)) + for jj in (_KEEL_IDX, _SHEER_IDX) + tuple(range(ot.n_waterlines)): + d = _dist(pos, self._screen_pt(i, jj)) if d < best_d: - best_d, best_idx = d, (i, j) + best_d, best_idx = d, (i, jj) return best_idx def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx sign = 1.0 if i >= ot.n_stations // 2 else -1.0 - wx, _ = self._s2w(pos.x(), pos.y()) + wx, wz = self._s2w(pos.x(), pos.y()) + + if j == _KEEL_IDX: + kz = ot.keel_z + z_ceil = float(ot.z_waterlines[0]) - 1e-3 + new_z = float(np.clip(wz, -self._hull.depth, z_ceil)) + kz[i] = new_z + return + + if j == _SHEER_IDX: + if len(self._hull.sheer_z) != ot.n_stations: + self._hull.sheer_z = self._hull.get_sheer_z().copy() + z_floor = float(ot.z_waterlines[-1]) + 1e-3 + self._hull.sheer_z[i] = float(np.clip(wz, z_floor, self._hull.depth * 3.0)) + return + + # ── Semi-manga Y + altura Z — independientes por nodo via z_offsets ───── + # Y: semi-manga por-estación (no afecta a ninguna otra estación) new_y = max(0.0, sign * wx) - # Limitar al doble de la manga para evitar explosiones new_y = min(new_y, self._hull.beam) ot.data[i, j] = new_y + # Z: z_offsets[i, j] permite mover este nodo verticalmente sin alterar + # ningún otro nodo (ni la misma LdA j en otras estaciones). + z_ref = float(ot.z_waterlines[j]) + keel_i = float(ot.keel_z[i]) + sheer_i = float(self._hull.get_sheer_z()[i]) + new_z = float(np.clip(wz, keel_i + 1e-3, sheer_i - 1e-3)) + if j > 0: + z_prev = float(ot.z_waterlines[j - 1]) + float(ot.z_offsets[i, j - 1]) + new_z = max(new_z, z_prev + 1e-3) + if j < ot.n_waterlines - 1: + z_next = float(ot.z_waterlines[j + 1]) + float(ot.z_offsets[i, j + 1]) + new_z = min(new_z, z_next - 1e-3) + ot.z_offsets[i, j] = new_z - z_ref # ── Dibujo ──────────────────────────────────────────────────────────────── @@ -547,10 +1286,10 @@ class BodyPlanViewer(_BaseViewer): T = self._hull.draft n = ot.n_stations - x_max = ot.max_half_breadth * 1.15 + x_max = ot.max_half_breadth * 1.15 + z_top = max(float(self._hull.get_sheer_z().max()), float(ot.z_waterlines[-1])) # ══ 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: @@ -563,12 +1302,15 @@ class BodyPlanViewer(_BaseViewer): 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)) + p.drawLine(self._w2s(0, 0), self._w2s(0, z_top * 1.10)) # ══ CAPA 2: Malla de control (control net — thin, muted) ══════ _draw_cnet_bodyplan(p, ot, self._w2s) - # ══ CAPA 3: Curvas del casco (bold, saturated) ════════════════ + # ══ CAPA 3: Curvas del casco desde la malla de control ═══════ + # Se usan las secciones de la malla (x_stations[i]) para que los + # nodos de CAPA 4 queden exactamente sobre las curvas. + sheer_z_arr = self._hull.get_sheer_z() for i in range(n): is_fwd = i >= n // 2 is_mid = i == n // 2 @@ -582,52 +1324,92 @@ class BodyPlanViewer(_BaseViewer): 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 + y_arr = ot.data[i, :] + z_arr = ot.z_waterlines + ot.z_offsets[i, :] + sign = 1.0 if is_fwd else -1.0 + keel_z_i = float(ot.keel_z[i]) + sheer_z_i = float(sheer_z_arr[i]) - # B-spline suave desde la quilla hasta la cubierta. - # NO se cierra la spline con (0,0) ya que eso distorsiona la curva; - # en su lugar se añade un segmento recto de cierre a la quilla. - raw = np.column_stack([y_arr * sign, z_arr]) - smooth = _smooth_pts(raw, n=80) + keel_pt = np.array([[0.0, keel_z_i]]) + raw_wl = np.column_stack([y_arr * sign, z_arr]) + n_wl = ot.n_waterlines + # Solo añadir sheer_pt si el sheer está POR ENCIMA del último waterplane. + # Cuando z_wl[-1] == depth == sheer_z el punto ya está en raw_wl[-1]. + sheer_above_wl = sheer_z_i > float(z_arr[-1]) + 1e-3 + if sheer_above_wl: + sheer_pt = np.array([[float(y_arr[-1]) * sign, sheer_z_i]]) + raw = np.vstack([keel_pt, raw_wl, sheer_pt]) + corner_mask = ( + [self._hull.is_corner(i, _KEEL_IDX)] + + [self._hull.is_corner(i, j) for j in range(n_wl)] + + [self._hull.is_corner(i, _SHEER_IDX)] + ) + else: + raw = np.vstack([keel_pt, raw_wl]) + corner_mask = ( + [self._hull.is_corner(i, _KEEL_IDX)] + + [self._hull.is_corner(i, j) for j in range(n_wl)] + ) + smooth = _smooth_curve_segs(raw, corner_mask, n=80) + # Clip al semiplano correcto — la semi-manga nunca cruza la crujía + if sign > 0: + smooth[:, 0] = np.clip(smooth[:, 0], 0.0, None) + else: + smooth[:, 0] = np.clip(smooth[:, 0], None, 0.0) path = QPainterPath() - for k_pt in range(len(smooth)): - pt = self._w2s(smooth[k_pt, 0], smooth[k_pt, 1]) - if k_pt == 0: - path.moveTo(pt) - else: - path.lineTo(pt) - # Cierre recto al punto de quilla en crujía (0, 0) - path.lineTo(self._w2s(0.0, 0.0)) + path.moveTo(self._w2s(float(smooth[0, 0]), float(smooth[0, 1]))) + for k_pt in range(1, len(smooth)): + path.lineTo(self._w2s(float(smooth[k_pt, 0]), float(smooth[k_pt, 1]))) p.drawPath(path) # 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)) - # ══ CAPA 4: Nodos (cuadrados naranjas — siempre encima) ═══════ + # ══ CAPA 4: Nodos (cuadrados — siempre encima) ════════════════ for i in range(n): + self._draw_control_point(p, self._screen_pt(i, _KEEL_IDX), (i, _KEEL_IDX)) + self._draw_control_point(p, self._screen_pt(i, _SHEER_IDX), (i, _SHEER_IDX)) for j in range(ot.n_waterlines): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) - # ── Peine de curvatura (toggle C) ───────────────────────────── + # ── Peine de curvatura estilo Delftship (toggle C) ─────────────── + # Muestra pelos solo en la estación seleccionada; si no hay nodo + # seleccionado muestra todas las estaciones (modo exploración). if self._show_curvature: + # _selected_curve ("sta", i) tiene prioridad sobre nodo seleccionado + if self._selected_curve is not None and self._selected_curve[0] == "sta": + sel_i = self._selected_curve[1] + elif self._selected_idx is not None: + sel_i = self._selected_idx[0] + else: + sel_i = None for i in range(n): + if sel_i is not None and i != sel_i: + continue sign = 1.0 if i >= n // 2 else -1.0 - z_arr = ot.z_waterlines + z_arr = ot.z_waterlines + ot.z_offsets[i, :] y_arr = ot.data[i, :] - # En el body plan: curva en espacio (z, y) — normal en dirección y _draw_curvature_comb( p, xs=z_arr, ys=y_arr * sign, w2s_fn=lambda z, y: self._w2s(y, z), - scale=ot.draft * 0.25, - color_pos=QColor("#ff6b6b"), - color_neg=QColor("#6baaff"), + scale=ot.draft * 0.40, + color_pos=QColor("#b060e0"), + color_neg=QColor("#7030b0"), ) + # ── Curva seleccionada Shift+clic (highlight estilo Delftship) ────── + if self._selected_curve is not None: + ctype, cidx = self._selected_curve + if ctype == "sta" and cidx is not None: + p.setPen(QPen(QColor("#00FFB0"), 2.5)) + seq = (_KEEL_IDX,) + tuple(range(ot.n_waterlines)) + (_SHEER_IDX,) + for k in range(len(seq) - 1): + p.drawLine(self._screen_pt(cidx, seq[k]), + self._screen_pt(cidx, seq[k + 1])) + self._draw_hint_overlay(p) self._draw_label(p, "BODY PLAN") p.end() @@ -651,7 +1433,11 @@ class ProfileViewer(_BaseViewer): • Línea de quilla. • Marcas de estación (verticales). - Vista de sólo lectura (no editable directamente). + Edición interactiva: + • Arrastrar un nodo en X mueve la estación longitudinalmente + (AP/FP fijos; estaciones intermedias con orden preservado). + • Arrastrar un nodo en Z mueve la línea de agua verticalmente + (j=0 quilla fija en 0; vecinas con orden preservado). Igual que en BodyPlanViewer, se invierte el eje Y. """ @@ -692,13 +1478,176 @@ class ProfileViewer(_BaseViewer): def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None + ot = self._hull.offsets + top = max(float(self._hull.get_sheer_z().max()), self._hull.depth) * 1.18 + bot = min(float(ot.keel_z.min()), 0.0) * 1.15 - self._hull.draft * 0.10 return ( -self._hull.lpp * 0.05, - -self._hull.draft * 0.15, + bot, self._hull.lpp * 1.05, - self._hull.draft * 1.30, + top, ) + # ── Edición ─────────────────────────────────────────────────────────────── + + def _screen_pt(self, i: int, j: int) -> QPointF: + """Nodo (i, j) en pantalla. + + j = _KEEL_IDX (-1) : quilla → (x_sta[i], keel_z[i]) + j = _SHEER_IDX (-2) : cubierta → (x_sta[i], sheer_z[i]) + j = _STEM_IDX (-10): roda → stem_ctrl[i] + j = _TRANS_IDX (-20): espejo → transom_ctrl[i] + j >= 0 : LdA normal → (x_sta[i], z_wl[j]) + """ + ot = self._hull.offsets + if j == _STEM_IDX: + c = self._hull.get_stem_ctrl() + return self._w2s(float(c[i, 0]), float(c[i, 1])) + if j == _TRANS_IDX: + c = self._hull.get_transom_ctrl() + return self._w2s(float(c[i, 0]), float(c[i, 1])) + xi = float(ot.x_stations[i]) + if j == _KEEL_IDX: + return self._w2s(xi + float(self._hull.get_keel_x_offsets()[i]), float(ot.keel_z[i])) + if j == _SHEER_IDX: + return self._w2s(xi + float(self._hull.get_sheer_x_offsets()[i]), float(self._hull.get_sheer_z()[i])) + x_eff = xi + float(ot.x_offsets[i, j]) + return self._w2s(x_eff, float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j])) + + def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]: + """Añade aristas de quilla y cubierta al hit-test base.""" + if self._hull is None: + return None + ot = self._hull.offsets + n_sta = ot.n_stations + THRESHOLD = _CPT_HIT * 2.0 + best_d, result = THRESHOLD, None + # Aristas de quilla + for i in range(n_sta - 1): + d = _dist_to_segment(pos, self._screen_pt(i, _KEEL_IDX), + self._screen_pt(i + 1, _KEEL_IDX)) + if d < best_d: + best_d, result = d, ("keel", None) + # Aristas de cubierta + for i in range(n_sta - 1): + d = _dist_to_segment(pos, self._screen_pt(i, _SHEER_IDX), + self._screen_pt(i + 1, _SHEER_IDX)) + if d < best_d: + best_d, result = d, ("sheer", None) + # Aristas de LdA de la malla base (si hay alguna más cercana) + base = super()._hit_test_edge(pos) + return result if result is not None else base + + def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: + if self._hull is None: + return None + ot = self._hull.offsets + best_d, best_idx = _CPT_HIT, None + + # Roda — solo puntos INTERMEDIOS (extremos fijados a quilla/sheer) + n_stem = len(self._hull.get_stem_ctrl()) + for k in range(1, n_stem - 1): + d = _dist(pos, self._screen_pt(k, _STEM_IDX)) + if d < best_d: + best_d, best_idx = d, (k, _STEM_IDX) + # Espejo — solo puntos INTERMEDIOS + n_trans = len(self._hull.get_transom_ctrl()) + for k in range(1, n_trans - 1): + d = _dist(pos, self._screen_pt(k, _TRANS_IDX)) + if d < best_d: + best_d, best_idx = d, (k, _TRANS_IDX) + # Quilla, cubierta y LdA — TODOS los nodos editables en X+Z + # (Vista Perfil: ejes X longitudinal y Z vertical — regla de ejes) + for i in range(ot.n_stations): + for jj in (_KEEL_IDX, _SHEER_IDX) + tuple(range(ot.n_waterlines)): + d = _dist(pos, self._screen_pt(i, jj)) + if d < best_d: + best_d, best_idx = d, (i, jj) + return best_idx + + def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: + ot = self._hull.offsets + i, j = idx + wx, wz = self._s2w(pos.x(), pos.y()) + + # ── Roda — solo puntos intermedios (extremos fijados a quilla/sheer-FP) ── + if j == _STEM_IDX: + ctrl = self._hull.get_stem_ctrl() + if i == 0 or i == len(ctrl) - 1: + return # endpoints son controlled por keel/sheer + if self._hull.stem_ctrl.shape[0] < 3: + self._hull.stem_ctrl = ctrl.copy() + self._hull.stem_ctrl[i, 0] = float(wx) + self._hull.stem_ctrl[i, 1] = float(wz) + return + + # ── Espejo — solo puntos intermedios ────────────────────────────────── + if j == _TRANS_IDX: + ctrl = self._hull.get_transom_ctrl() + if i == 0 or i == len(ctrl) - 1: + return + if self._hull.transom_ctrl.shape[0] < 3: + self._hull.transom_ctrl = ctrl.copy() + self._hull.transom_ctrl[i, 0] = float(wx) + self._hull.transom_ctrl[i, 1] = float(wz) + return + + # ── X: per-node x_offsets — x_stations es INMUTABLE en drag ───────── + x_ref = float(ot.x_stations[i]) + if j in (_KEEL_IDX, _SHEER_IDX): + kx = (self._hull.get_keel_x_offsets() + if j == _KEEL_IDX else self._hull.get_sheer_x_offsets()) + new_x = float(np.clip(wx, -self._hull.lpp * 0.2, self._hull.lpp * 1.2)) + if i > 0: + new_x = max(new_x, float(ot.x_stations[i - 1]) + float(kx[i - 1]) + 0.01) + if i < ot.n_stations - 1: + new_x = min(new_x, float(ot.x_stations[i + 1]) + float(kx[i + 1]) - 0.01) + if j == _KEEL_IDX: + if len(self._hull.keel_x_offsets) != ot.n_stations: + self._hull.keel_x_offsets = np.zeros(ot.n_stations) + self._hull.keel_x_offsets[i] = new_x - x_ref + else: + if len(self._hull.sheer_x_offsets) != ot.n_stations: + self._hull.sheer_x_offsets = np.zeros(ot.n_stations) + self._hull.sheer_x_offsets[i] = new_x - x_ref + elif 0 < i < ot.n_stations - 1: + new_x = float(np.clip(wx, 0.0, self._hull.lpp)) + x_prev = float(ot.x_stations[i - 1]) + float(ot.x_offsets[i - 1, j]) + x_next = float(ot.x_stations[i + 1]) + float(ot.x_offsets[i + 1, j]) + new_x = max(new_x, x_prev + 0.01) + new_x = min(new_x, x_next - 0.01) + ot.x_offsets[i, j] = new_x - x_ref + else: + # Nodos de borde (AP i=0 / FP i=n_sta-1): X libre — DEFINEN el contorno + new_x = float(np.clip(wx, -self._hull.lpp * 0.15, self._hull.lpp * 1.15)) + ot.x_offsets[i, j] = new_x - x_ref + + # ── Z ───────────────────────────────────────────────────────────────── + if j == _KEEL_IDX: + kz = ot.keel_z + z_top = float(ot.z_waterlines[0]) - 1e-3 + kz[i] = float(np.clip(wz, -self._hull.depth * 2, z_top)) + + elif j == _SHEER_IDX: + if len(self._hull.sheer_z) != ot.n_stations: + self._hull.sheer_z = self._hull.get_sheer_z().copy() + z_floor = float(ot.z_waterlines[-1]) + 1e-3 + self._hull.sheer_z[i] = float(np.clip(wz, z_floor, self._hull.depth * 3.0)) + + else: + # Z: independiente por nodo — z_offsets[i, j] sin alterar z_waterlines + z_ref = float(ot.z_waterlines[j]) + keel_i = float(ot.keel_z[i]) + sheer_i = float(self._hull.get_sheer_z()[i]) + new_z = float(np.clip(wz, keel_i + 1e-3, sheer_i - 1e-3)) + if j > 0: + z_prev = float(ot.z_waterlines[j - 1]) + float(ot.z_offsets[i, j - 1]) + new_z = max(new_z, z_prev + 1e-3) + if j < ot.n_waterlines - 1: + z_next = float(ot.z_waterlines[j + 1]) + float(ot.z_offsets[i, j + 1]) + new_z = min(new_z, z_next - 1e-3) + ot.z_offsets[i, j] = new_z - z_ref + def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -709,16 +1658,27 @@ class ProfileViewer(_BaseViewer): p.end() return - ot = self._hull.offsets - T = self._hull.draft - Lpp = self._hull.lpp + ot = self._hull.offsets + T = self._hull.draft + Lpp = self._hull.lpp + D = self._hull.depth + sheer = self._hull.get_sheer_z() + keel = ot.keel_z + x_sta = ot.x_stations + z_bot = min(float(keel.min()), 0.0) - # ── Grilla de estaciones ─────────────────────────────────────── + # ── Grilla cartesiana de medición (fondo) ───────────────────────── + _draw_dim_grid(p, self._w2s, self._s2w, self.width(), self.height()) + + # ── Grilla de estaciones — planos en station_planes ────────────── + station_xk = self._hull.get_station_planes() 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)) + for xk in station_xk: + z_lo = float(np.interp(xk, x_sta, keel)) - T * 0.05 + z_hi = float(np.interp(xk, x_sta, sheer)) + T * 0.05 + p.drawLine(self._w2s(xk, z_lo), self._w2s(xk, z_hi)) - # ── Líneas de agua en perfil ─────────────────────────────────── + # ── Líneas de agua de referencia ────────────────────────────── for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: @@ -729,39 +1689,36 @@ class ProfileViewer(_BaseViewer): color.setAlphaF(0.40 + 0.50 * frac) p.setPen(QPen(color, 0.9)) p.drawLine(self._w2s(0, z), self._w2s(Lpp, z)) + # Etiqueta de calado de diseño + if is_design: + lp = self._w2s(Lpp, z) + p.setFont(QFont("Monospace", 7)) + p.setPen(QPen(_WL_DESIGN)) + p.drawText( + QRectF(lp.x() + 4, lp.y() - 8, 70, 14), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"T = {T:.2f} m", + ) - # ── Cubierta ────────────────────────────────────────────────── - p.setPen(QPen(_DECK, 1.8)) - path_deck = QPainterPath() - for k, x in enumerate(ot.x_stations): - pt = self._w2s(x, self._hull.depth) - if k == 0: - path_deck.moveTo(pt) - else: - path_deck.lineTo(pt) - p.drawPath(path_deck) - - # ── Quilla ──────────────────────────────────────────────────── - p.setPen(QPen(_KEEL, 2.0)) - p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0)) - - # ── Líneas de pantoque (buttock lines) ──────────────────────── - # Planos verticales a Y = B/4, B/2, 3B/4 del eje de crujía. - # Cada pantoque es una curva (x, z) que muestra el run del casco - # a una distancia lateral constante del plano de crujía. - y_max = ot.max_half_breadth + # ── Líneas de pantoque (buttock lines) ───────────────────────── + y_max = ot.max_half_breadth _N_BUTT = 3 for b_idx in range(1, _N_BUTT + 1): y_b = y_max * b_idx / (_N_BUTT + 1) - pts = _compute_buttock_pts(ot, y_b) + pts = _compute_buttock_pts( + ot, y_b, + keel_z=keel, sheer_z=sheer, + keel_x_off=self._hull.get_keel_x_offsets(), + sheer_x_off=self._hull.get_sheer_x_offsets(), + ) if len(pts) < 2: continue arr = np.array(pts, dtype=float) smooth = _smooth_pts(arr, n=80) frac = b_idx / _N_BUTT - col = QColor(_WATERLINE) - col.setAlphaF(0.38 + 0.45 * frac) - p.setPen(QPen(col, 1.1)) + col = QColor(_BUTTOCK) + col.setAlphaF(0.50 + 0.40 * frac) + p.setPen(QPen(col, 1.2)) p.setBrush(Qt.BrushStyle.NoBrush) path = QPainterPath() for k_pt in range(len(smooth)): @@ -772,23 +1729,312 @@ class ProfileViewer(_BaseViewer): path.lineTo(pt) p.drawPath(path) - # ── Perpendiculares AP / FP ──────────────────────────────────── - p.setPen(QPen(_AXIS, 1.5)) - p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05)) - p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05)) + # ── Malla de control del perfil (aristas de poliedro de control) ───── + # Aristas verticales: keel → wl[0] → ... → wl[n-1] → sheer por estación + # Aristas horizontales: misma LdA / quilla / cubierta entre estaciones + pen_cv = QPen(_CNET_TRAN, 0.9, Qt.PenStyle.DotLine) + pen_ch = QPen(_CNET_LONG, 0.9, Qt.PenStyle.DotLine) + p.setBrush(Qt.BrushStyle.NoBrush) + for i in range(ot.n_stations): + # Columna vertical de esta estación + col_pts = ( + [self._screen_pt(i, _KEEL_IDX)] + + [self._screen_pt(i, j) for j in range(ot.n_waterlines)] + + [self._screen_pt(i, _SHEER_IDX)] + ) + p.setPen(pen_cv) + for k in range(len(col_pts) - 1): + p.drawLine(col_pts[k], col_pts[k + 1]) + # Aristas horizontales por línea de agua + p.setPen(pen_ch) + for j in range(ot.n_waterlines): + for i in range(ot.n_stations - 1): + p.drawLine(self._screen_pt(i, j), self._screen_pt(i + 1, j)) + # Aristas horizontales quilla y cubierta + for i in range(ot.n_stations - 1): + p.drawLine(self._screen_pt(i, _KEEL_IDX), self._screen_pt(i + 1, _KEEL_IDX)) + p.drawLine(self._screen_pt(i, _SHEER_IDX), self._screen_pt(i + 1, _SHEER_IDX)) + # ── Contorno del perfil — CONECTADO (quilla→roda→sheer→espejo) ────── + # get_stem_ctrl/get_transom_ctrl garantizan snap de extremos: + # stem[0] = keel[-1] = (x_fp, keel_z[-1]) + # stem[-1] = sheer[-1]= (x_fp, sheer_z[-1]) + # trans[0] = keel[0] = (x_ap, keel_z[0]) + # trans[-1] = sheer[0]= (x_ap, sheer_z[0]) + n_sta = ot.n_stations + n_wl = ot.n_waterlines + keel_x_eff = x_sta + self._hull.get_keel_x_offsets() + sheer_x_eff = x_sta + self._hull.get_sheer_x_offsets() + keel_arr = np.column_stack([keel_x_eff, keel]) + keel_smo = _smooth_curve_segs( + keel_arr, + [self._hull.is_corner(i, _KEEL_IDX) for i in range(n_sta)], + n=80, + ) + sheer_arr = np.column_stack([sheer_x_eff, sheer]) + sheer_smo = _smooth_curve_segs( + sheer_arr, + [self._hull.is_corner(i, _SHEER_IDX) for i in range(n_sta)], + n=80, + ) + + # Roda y espejo derivan de la COLUMNA DE BORDE del mallado. + # Los nodos de borde DEFINEN el contorno — no lo siguen. + kx = self._hull.get_keel_x_offsets() + sx = self._hull.get_sheer_x_offsets() + x_fp = float(ot.x_stations[-1]) + x_ap = float(ot.x_stations[0]) + # Helper: construye puntos de contorno vertical (roda / espejo). + # Sólo añade el nodo sheer si está POR ENCIMA del último waterplane. + def _boundary_col_pts(i_sta: int, x_base: float) -> tuple[np.ndarray, list[bool]]: + wl_pts = np.column_stack([ + x_base + ot.x_offsets[i_sta, :], + ot.z_waterlines + ot.z_offsets[i_sta, :], + ]) + sheer_z_b = float(sheer[i_sta]) + add_sheer = sheer_z_b > float(wl_pts[-1, 1]) + 1e-3 + keel_row = np.array([[x_base + float(kx[i_sta]), float(keel[i_sta])]]) + if add_sheer: + sheer_row = np.array([[x_base + float(sx[i_sta]), sheer_z_b]]) + pts = np.vstack([keel_row, wl_pts, sheer_row]) + mask = ( + [self._hull.is_corner(i_sta, _KEEL_IDX)] + + [self._hull.is_corner(i_sta, j) for j in range(n_wl)] + + [self._hull.is_corner(i_sta, _SHEER_IDX)] + ) + else: + pts = np.vstack([keel_row, wl_pts]) + mask = ( + [self._hull.is_corner(i_sta, _KEEL_IDX)] + + [self._hull.is_corner(i_sta, j) for j in range(n_wl)] + ) + return pts, mask + + stem_pts, stem_mask = _boundary_col_pts(n_sta - 1, x_fp) + stem_smo = _smooth_curve_segs(stem_pts, stem_mask, n=60) + trans_pts, trans_mask = _boundary_col_pts(0, x_ap) + trans_smo = _smooth_curve_segs(trans_pts, trans_mask, n=60) + + def _outline_seg(pen: QPen, pts: np.ndarray, reverse: bool = False) -> None: + seq = pts[::-1] if reverse else pts + path = QPainterPath() + for k_pt, row in enumerate(seq): + pt = self._w2s(float(row[0]), float(row[1])) + if k_pt == 0: + path.moveTo(pt) + else: + path.lineTo(pt) + p.setPen(pen) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawPath(path) + + # Silueta cerrada — un solo QPainterPath garantiza cero huecos. + # Orden: quilla(AP→FP) → roda(↑) → cubierta(FP→AP) → espejo(↓) → cierre + sil = QPainterPath() + sil.moveTo(self._w2s(float(keel_smo[0, 0]), float(keel_smo[0, 1]))) + for _r in keel_smo[1:]: + sil.lineTo(self._w2s(float(_r[0]), float(_r[1]))) + for _r in stem_smo: + sil.lineTo(self._w2s(float(_r[0]), float(_r[1]))) + for _r in sheer_smo[::-1]: + sil.lineTo(self._w2s(float(_r[0]), float(_r[1]))) + for _r in trans_smo[::-1]: + sil.lineTo(self._w2s(float(_r[0]), float(_r[1]))) + sil.closeSubpath() + p.setPen(QPen(QColor("#b0c8e0"), 2.0)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawPath(sil) + # Acento de color por segmento (encima del path base) + _outline_seg(QPen(_KEEL, 1.8), keel_smo) + _outline_seg(QPen(_STEM_COLOR, 1.8), stem_smo) + _outline_seg(QPen(_DECK, 1.5), sheer_smo, reverse=True) + _outline_seg(QPen(_TRANSOM_COLOR, 1.8), trans_smo, reverse=True) + + # ── Perpendiculares AP / FP ──────────────────────────────────── + p.setPen(QPen(_AXIS, 1.0, Qt.PenStyle.DashLine)) + p.drawLine(self._w2s(0, float(keel[0]) - T * 0.08), + self._w2s(0, float(sheer[0]) + T * 0.08)) + p.drawLine(self._w2s(Lpp, float(keel[-1]) - T * 0.08), + self._w2s(Lpp, float(sheer[-1]) + T * 0.08)) + + # ── Curvas suaves de líneas de agua (perfil longitudinal de cada LdA) ── + # Se calculan aquí para reutilizarlas en el peine y en el highlight. + wl_smooths: list[np.ndarray] = [] + for j in range(ot.n_waterlines): + xs_wl = x_sta + ot.x_offsets[:, j] + zs_wl = float(ot.z_waterlines[j]) + ot.z_offsets[:, j] + pts_wl = np.column_stack([xs_wl, zs_wl]) + corners_wl = [self._hull.is_corner(i, j) for i in range(ot.n_stations)] + wl_smooths.append(_smooth_curve_segs(pts_wl, corners_wl, n=60)) + + # ── Peine de curvatura estilo Delftship (toggle C) ─────────────────── + # nodo keel/sheer seleccionado → peine de esa curva + # nodo LdA j seleccionado → peine de esa LdA (en cian) + # sin selección → peine de quilla + cubierta + if self._show_curvature: + sel_j = self._selected_idx[1] if self._selected_idx is not None else None + comb_scale = D * 0.35 + cc_pos = QColor("#b060e0") + cc_neg = QColor("#7030b0") + + show_keel = sel_j is None or sel_j == _KEEL_IDX + show_sheer = sel_j is None or sel_j == _SHEER_IDX + + if show_keel: + _draw_curvature_comb( + p, xs=keel_x_eff, ys=keel, + w2s_fn=self._w2s, scale=comb_scale, + color_pos=cc_pos, color_neg=cc_neg, + ) + if show_sheer: + _draw_curvature_comb( + p, xs=sheer_x_eff, ys=sheer, + w2s_fn=self._w2s, scale=comb_scale, + color_pos=cc_pos, color_neg=cc_neg, + ) + if sel_j is not None and 0 <= sel_j < len(wl_smooths): + sm = wl_smooths[sel_j] + if len(sm) >= 3: + _draw_curvature_comb( + p, xs=sm[:, 0], ys=sm[:, 1], + w2s_fn=self._w2s, scale=comb_scale, + color_pos=QColor("#00d8ff"), + color_neg=QColor("#0090cc"), + ) + + # ── Nodos editables ──────────────────────────────────────────────────── + for i in range(ot.n_stations): + self._draw_control_point(p, self._screen_pt(i, _KEEL_IDX), (i, _KEEL_IDX)) + self._draw_control_point(p, self._screen_pt(i, _SHEER_IDX), (i, _SHEER_IDX)) + for j in range(ot.n_waterlines): + self._draw_control_point(p, self._screen_pt(i, j), (i, j)) + + # ── Etiquetas AP / FP ───────────────────────────────────────── p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) _lbl = lambda text, x, z: p.drawText( QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14), - Qt.AlignmentFlag.AlignCenter, text + Qt.AlignmentFlag.AlignCenter, text, ) - _lbl("AP", 0, -T * 0.12) - _lbl("FP", Lpp, -T * 0.12) + _lbl("AP", 0, z_bot - T * 0.12) + _lbl("FP", Lpp, z_bot - T * 0.12) + + # ── Highlight curva seleccionada (smooth) ───────────────────────────── + # Prioridad: Shift+clic en curva > nodo LdA activo seleccionado. + highlight_j: Optional[int] = None + sel_j_node = self._selected_idx[1] if self._selected_idx is not None else None + if sel_j_node is not None and 0 <= sel_j_node < ot.n_waterlines: + highlight_j = sel_j_node + + if self._selected_curve is not None: + ctype, curve_j = self._selected_curve + p.setBrush(Qt.BrushStyle.NoBrush) + if ctype == "keel": + p.setPen(QPen(QColor("#00FFB0"), 2.5)) + path = QPainterPath() + for k_pt, row in enumerate(keel_smo): + pt = self._w2s(float(row[0]), float(row[1])) + if k_pt == 0: path.moveTo(pt) + else: path.lineTo(pt) + p.drawPath(path) + elif ctype == "sheer": + p.setPen(QPen(QColor("#00FFB0"), 2.5)) + path = QPainterPath() + for k_pt, row in enumerate(sheer_smo[::-1]): + pt = self._w2s(float(row[0]), float(row[1])) + if k_pt == 0: path.moveTo(pt) + else: path.lineTo(pt) + p.drawPath(path) + elif ctype == "wl" and curve_j is not None: + highlight_j = curve_j # smooth highlight below + + if highlight_j is not None and highlight_j < len(wl_smooths): + sm = wl_smooths[highlight_j] + if len(sm) >= 2: + is_design = abs(float(ot.z_waterlines[highlight_j]) - T) < 1e-6 + hl_col = QColor("#00FFD0") if is_design else QColor("#5acdff") + p.setPen(QPen(hl_col, 2.2)) + p.setBrush(Qt.BrushStyle.NoBrush) + path = QPainterPath() + for k_pt, row in enumerate(sm): + pt = self._w2s(float(row[0]), float(row[1])) + if k_pt == 0: path.moveTo(pt) + else: path.lineTo(pt) + p.drawPath(path) self._draw_label(p, "PERFIL LATERAL") p.end() + def contextMenuEvent(self, event) -> None: # noqa: N802 + """Menú contextual: insertar LdA/estación, esquina, roda/espejo.""" + if self._hull is None: + return + from PySide6.QtWidgets import QMenu + wx, wz = self._s2w(event.pos().x(), event.pos().y()) + ot = self._hull.offsets + menu = QMenu(self) + act_wl = menu.addAction(f"Insertar línea de agua z = {wz:.3f} m") + act_sta = menu.addAction(f"Insertar estación x = {wx:.3f} m") + # Esquina — visible solo cuando hay nodo bajo el cursor + act_corner = None + from PySide6.QtCore import QPointF + hit_idx = self._hit_test(QPointF(event.pos())) + if hit_idx is not None: + hi, hj = hit_idx + if hj not in (_STEM_IDX, _TRANS_IDX): + is_c = self._hull.is_corner(hi, hj) + label = ("Desmarcar esquina (suavizar)" if is_c + else "Marcar como esquina (sharp)") + menu.addSeparator() + act_corner = menu.addAction(label) + # Añadir punto de control a roda o espejo si se hace clic cerca de ellos + menu.addSeparator() + act_stem = menu.addAction("Añadir punto de control a la roda") + act_trans = menu.addAction("Añadir punto de control al espejo") + result = menu.exec(event.globalPos()) + if result == act_wl: + z = float(np.clip(wz, float(ot.z_waterlines[0]) + 1e-3, + float(ot.z_waterlines[-1]) - 1e-3)) + self._hull.insert_waterline(z) + self._fit_to_view() + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif result == act_sta: + x = float(np.clip(wx, float(ot.x_stations[0]) + 1e-3, + float(ot.x_stations[-1]) - 1e-3)) + self._hull.insert_station(x) + self._fit_to_view() + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif result == act_stem: + ctrl = self._hull.get_stem_ctrl().copy() + # Insertar a mitad del segmento más cercano al clic + dists = [np.hypot(wx - ctrl[k, 0], wz - ctrl[k, 1]) for k in range(len(ctrl))] + idx = int(np.argmin(dists)) + idx = max(1, min(idx, len(ctrl) - 1)) # entre interior bounds + new_pt = ((ctrl[idx - 1] + ctrl[idx]) / 2).reshape(1, 2) + self._hull.stem_ctrl = np.insert(ctrl, idx, new_pt, axis=0) + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif result == act_trans: + ctrl = self._hull.get_transom_ctrl().copy() + dists = [np.hypot(wx - ctrl[k, 0], wz - ctrl[k, 1]) for k in range(len(ctrl))] + idx = int(np.argmin(dists)) + idx = max(1, min(idx, len(ctrl) - 1)) + new_pt = ((ctrl[idx - 1] + ctrl[idx]) / 2).reshape(1, 2) + self._hull.transom_ctrl = np.insert(ctrl, idx, new_pt, axis=0) + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif act_corner is not None and result == act_corner and hit_idx is not None: + hi, hj = hit_idx + self._hull.toggle_corner(hi, hj) + # Actualizar panel de info si el nodo sigue seleccionado + if self._selected_idx == hit_idx: + x, y, z = self._node_world_xyz(hit_idx) + self._info_panel.update_node(x, y, z, self._hull.is_corner(hi, hj)) + self.offsets_edited.emit(self._hull.offsets) + self.update() + # ───────────────────────────────────────────────────────────────────────────── # 3. Plan Viewer — vista de planta @@ -819,7 +2065,8 @@ class PlanViewer(_BaseViewer): def _screen_pt(self, i: int, j: int) -> QPointF: ot = self._hull.offsets - return self._w2s(ot.x_stations[i], ot.data[i, j]) + x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j]) + return self._w2s(x_eff, ot.data[i, j]) def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: @@ -836,9 +2083,20 @@ class PlanViewer(_BaseViewer): def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx - _, wy = self._s2w(pos.x(), pos.y()) + wx, wy = self._s2w(pos.x(), pos.y()) + # ── Semi-manga (eje Y del casco) — clamp en [0, beam], sin rebote ── new_y = max(0.0, min(wy, self._hull.beam)) ot.data[i, j] = new_y + # ── Posición longitudinal del nodo — per-node x_offsets ─────────── + # x_stations es INMUTABLE en drag; x_offsets[i,j] almacena la desviación. + if 0 < i < ot.n_stations - 1: + x_ref = float(ot.x_stations[i]) + new_x = float(np.clip(wx, 0.0, self._hull.lpp)) + x_prev = float(ot.x_stations[i - 1]) + float(ot.x_offsets[i - 1, j]) + x_next = float(ot.x_stations[i + 1]) + float(ot.x_offsets[i + 1, j]) + new_x = max(new_x, x_prev + 0.01) + new_x = min(new_x, x_next - 0.01) + ot.x_offsets[i, j] = new_x - x_ref # ── Dibujo ──────────────────────────────────────────────────────────────── @@ -862,10 +2120,11 @@ class PlanViewer(_BaseViewer): p.setPen(QPen(_AXIS, 1.2)) p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0)) - # Estaciones — líneas verticales en AMBOS semiplanos + # Estaciones — líneas verticales en AMBOS semiplanos (en station_planes) + station_xk = self._hull.get_station_planes() p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine)) - for x in ot.x_stations: - p.drawLine(self._w2s(x, -y_max * 1.10), self._w2s(x, y_max * 1.10)) + for xk in station_xk: + p.drawLine(self._w2s(xk, -y_max * 1.10), self._w2s(xk, y_max * 1.10)) # ══ CAPA 2: Poliedro de control (ambas mitades) ════════════════ _draw_cnet_planview(p, ot, self._w2s) @@ -892,8 +2151,10 @@ class PlanViewer(_BaseViewer): p.setPen(QPen(color, width)) p.setBrush(Qt.BrushStyle.NoBrush) - raw = np.column_stack([ot.x_stations, ot.data[:, j]]) + raw = np.column_stack([ot.x_stations + ot.x_offsets[:, j], ot.data[:, j]]) smooth = _smooth_pts(raw, n=80) + # La semi-manga no puede ser negativa (corrige oscilaciones del spline cerca de la proa) + smooth[:, 1] = np.clip(smooth[:, 1], 0.0, None) n_smo = len(smooth) # Coordenadas del eje de crujía en AP y FP (donde la LdA termina) @@ -918,24 +2179,108 @@ class PlanViewer(_BaseViewer): for j in range(n_wl): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) - # ── Peine de curvatura (toggle C) ───────────────────────────── + # ── Peine de curvatura estilo Delftship (toggle C) ─────────────── + # Sin selección → todas las LdAs (violeta, alpha gradual por altura). + # Nodo/curva sel. → SOLO esa LdA (cian brillante); el resto se oculta. if self._show_curvature: - x_arr = ot.x_stations + if self._selected_curve is not None and self._selected_curve[0] == "wl": + sel_j = self._selected_curve[1] + elif self._selected_idx is not None and self._selected_idx[1] >= 0: + sel_j = self._selected_idx[1] + else: + sel_j = None + for j in range(n_wl): + if sel_j is not None and j != sel_j: + continue # ocultar las demás LdAs + x_eff = ot.x_stations + ot.x_offsets[:, j] y_arr = ot.data[:, j] + if sel_j is not None: # seleccionada → cian + c_pos = QColor("#00d8ff") + c_neg = QColor("#0090cc") + else: # sin selección → violeta + frac = j / max(n_wl - 1, 1) + c_pos = QColor("#b060e0") + c_neg = QColor("#7030b0") + c_pos.setAlphaF(0.40 + 0.50 * frac) + c_neg.setAlphaF(0.40 + 0.50 * frac) _draw_curvature_comb( p, - xs=x_arr, ys=y_arr, + xs=x_eff, ys=y_arr, w2s_fn=self._w2s, - scale=self._hull.beam * 0.18, - color_pos=QColor("#ff6b6b"), - color_neg=QColor("#6baaff"), + scale=self._hull.beam * 0.30, + color_pos=c_pos, + color_neg=c_neg, ) + # ── Curva seleccionada Shift+clic (highlight estilo Delftship) ────── + if self._selected_curve is not None: + ctype, cidx = self._selected_curve + p.setPen(QPen(QColor("#00FFB0"), 2.5)) + if ctype == "wl" and cidx is not None: + for i in range(ot.n_stations - 1): + p.drawLine(self._screen_pt(i, cidx), + self._screen_pt(i + 1, cidx)) + elif ctype == "sta" and cidx is not None: + for j in range(ot.n_waterlines - 1): + p.drawLine(self._screen_pt(cidx, j), + self._screen_pt(cidx, j + 1)) + self._draw_hint_overlay(p) self._draw_label(p, "VISTA DE PLANTA") p.end() + def contextMenuEvent(self, event) -> None: # noqa: N802 + """Menú contextual: insertar estación, insertar LdA, corregir crujía.""" + if self._hull is None: + return + from PySide6.QtWidgets import QMenu + wx, wy = self._s2w(event.pos().x(), event.pos().y()) + ot = self._hull.offsets + menu = QMenu(self) + act_sta = menu.addAction(f"Insertar estación x = {wx:.3f} m") + act_wl = menu.addAction("Insertar línea de agua (editar en perfil)") + menu.addSeparator() + act_snap = menu.addAction("Corregir crujía — Y = 0 para nodos en línea central") + # Si hay un nodo seleccionado/hover cerca del eje, ofrecer acción individual + act_snap1 = None + from PySide6.QtCore import QPointF + idx = self._hit_test(QPointF(event.pos())) + if idx is not None: + i, j = idx + if j >= 0: + cur_y = float(ot.data[i, j]) + tol = ot.max_half_breadth * 0.10 + if abs(cur_y) < tol: + act_snap1 = menu.addAction( + f"Corregir este nodo Y {cur_y:+.4f} → 0" + ) + result = menu.exec(event.globalPos()) + if result == act_sta: + x = float(np.clip(wx, float(ot.x_stations[0]) + 1e-3, + float(ot.x_stations[-1]) - 1e-3)) + self._hull.insert_station(x) + self._fit_to_view() + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif result == act_snap: + # Snap a Y=0 todos los nodos dentro del 5% del semiplano + tol = ot.max_half_breadth * 0.05 + changed = False + for si in range(ot.n_stations): + for sj in range(ot.n_waterlines): + if abs(float(ot.data[si, sj])) < tol: + ot.data[si, sj] = 0.0 + changed = True + if changed: + self.offsets_edited.emit(self._hull.offsets) + self.update() + elif act_snap1 is not None and result == act_snap1: + i, j = idx + ot.data[i, j] = 0.0 + self.offsets_edited.emit(self._hull.offsets) + self.update() + # ───────────────────────────────────────────────────────────────────────────── # Utilidades internas @@ -945,6 +2290,54 @@ def _dist(a: QPointF, b: QPointF) -> float: return math.hypot(a.x() - b.x(), a.y() - b.y()) +def _dist_to_segment(pt: QPointF, a: QPointF, b: QPointF) -> float: + """Distancia perpendicular (en px) del punto pt al segmento ab.""" + dx, dy = b.x() - a.x(), b.y() - a.y() + len_sq = dx * dx + dy * dy + if len_sq < 1e-9: + return _dist(pt, a) + t = max(0.0, min(1.0, ((pt.x() - a.x()) * dx + (pt.y() - a.y()) * dy) / len_sq)) + return math.hypot(pt.x() - (a.x() + t * dx), pt.y() - (a.y() + t * dy)) + + +def _resample_curve_smooth( + xs: np.ndarray, ys: np.ndarray, n: int = 80 +) -> tuple[np.ndarray, np.ndarray]: + """Remuestrea la curva (xs, ys) en *n* puntos equidistantes en arco. + + Usa CubicSpline de scipy si está disponible (resultado suave), si no + cae a interpolación lineal (evita crash pero menos suave). + Los peines siempre tendrán al menos *n* pelos independientemente de cuántos + puntos tenga la tabla de offsets original. + """ + if len(xs) < 3: + return xs, ys + try: + from scipy.interpolate import CubicSpline + x_f = xs.astype(float) + y_f = ys.astype(float) + # Parametrización por longitud de arco + diffs = np.diff(np.column_stack([x_f, y_f]), axis=0) + ds = np.hypot(diffs[:, 0], diffs[:, 1]) + t = np.concatenate([[0.0], np.cumsum(ds)]) + # Eliminar duplicados + t_u, idx = np.unique(t, return_index=True) + if len(t_u) < 3: + return xs, ys + t_new = np.linspace(t_u[0], t_u[-1], n) + return (CubicSpline(t_u, x_f[idx])(t_new), + CubicSpline(t_u, y_f[idx])(t_new)) + except Exception: + # Fallback lineal + x_f = xs.astype(float) + y_f = ys.astype(float) + diffs = np.diff(np.column_stack([x_f, y_f]), axis=0) + ds = np.hypot(diffs[:, 0], diffs[:, 1]) + t = np.concatenate([[0.0], np.cumsum(ds)]) + t_new = np.linspace(t[0], t[-1], n) + return np.interp(t_new, t, x_f), np.interp(t_new, t, y_f) + + def _curvature_comb_data( xs: np.ndarray, ys: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -994,41 +2387,46 @@ def _draw_curvature_comb( color_neg: QColor, ) -> None: """ - Dibuja el peine de curvatura sobre la curva discreta (xs, ys). + Dibuja el peine de curvatura estilo Delftship sobre la curva (xs, ys). - Cada 'diente' es una línea perpendicular a la curva con longitud k·scale. - Se dibuja también el spine conectando las puntas de los dientes. + Los 'pelos' son perpendiculares a la curva: + • Longitud normalizada: max|κ| → scale (siempre visible aunque la curva sea suave) + • Sentido: positivo = curvatura convexa, negativo = inflexión (voltea al otro lado) + • Spine: línea que une las puntas de todos los pelos Parámetros ---------- - w2s_fn : callable(x, y) → QPointF - Función de conversión mundo→pantalla del visor. - scale : float - Factor de amplificación en unidades de mundo. - color_pos / color_neg : QColor - Colores para curvatura positiva / negativa. + scale : float — longitud máxima del pelo en unidades de mundo (el de max curvatura) """ if len(xs) < 3: return + # Remuestrear a 80 puntos equidistantes en arco para peines densos y suaves + xs, ys = _resample_curve_smooth(xs, ys, n=80) + kappas, nxs, nys = _curvature_comb_data(xs, ys) + # Normalizar: max|κ| → 1.0 para que los pelos sean siempre visibles + max_k = float(np.max(np.abs(kappas))) + if max_k < 1e-12: + return + norm_k = kappas / max_k # rango [-1, 1]; max longitud = scale + tips_world: list[Optional[tuple[float, float]]] = [] for i in range(len(xs)): - k = kappas[i] - if abs(k) < 1e-9: + k = norm_k[i] + if abs(k) < 1e-4: # extremos (siempre 0 por construcción) tips_world.append(None) continue ex = float(xs[i]) + nxs[i] * k * scale ey = float(ys[i]) + nys[i] * k * scale tips_world.append((ex, ey)) - # Diente col = color_pos if k > 0 else color_neg p.setPen(QPen(col, 0.8)) p.drawLine(w2s_fn(float(xs[i]), float(ys[i])), w2s_fn(ex, ey)) - # Spine (línea que une las puntas) + # Spine — curva que une las puntas (revela irregularidades de curvatura) spine = QPainterPath() started = False for tip in tips_world: diff --git a/docs/BITACORA.md b/docs/BITACORA.md new file mode 100644 index 0000000..4f235cd --- /dev/null +++ b/docs/BITACORA.md @@ -0,0 +1,549 @@ +# BITÁCORA DE DESARROLLO — AR-ShipDesign + +> **Propósito:** Registro técnico vivo de cada módulo funcional de la app. +> A diferencia de `CHANGELOG.md` (que registra versiones), esta bitácora documenta +> el estado interno de cada módulo: qué funciona, qué se corrigió, por qué se +> tomaron ciertas decisiones y qué queda pendiente. +> +> **Actualizar** al final de cada sesión de trabajo o al completar un feature. + +--- + +## Convenciones de estado + +| Símbolo | Significado | +|---------|-------------| +| ✅ | Implementado y verificado (tests + visual) | +| 🔧 | Implementado, pendiente verificación visual en la app | +| 🐛 | Bug conocido, no resuelto aún | +| 📋 | Planificado, no iniciado | +| ❌ | Descartado o revertido (con explicación) | +| ⚠️ | Restricción crítica — no romper | + +--- + +## Reglas Inquebrantables (leer SIEMPRE antes de editar los visores) + +### ⚠️ REGLA DE EJES — NUNCA VIOLAR + +``` +Vista Perfil (ProfileViewer) → nodos en EJE X (longitudinal) + EJE Z (vertical) +Vista Planta (PlanViewer) → nodos en EJE X (longitudinal) + EJE Y (transversal) +Vista Frontal (BodyPlanViewer) → nodos en EJE Y (transversal) + EJE Z (vertical) +``` + +Nunca bloquear nodos en ninguna vista. Nunca añadir restricciones `_hit_test` a nodos normales. +Si un cambio rompe el movimiento en alguno de estos ejes → **REVERTIR INMEDIATAMENTE**. + +### ⚠️ REGLA DE SNAP + +El snap de nodos de contorno (`snap_boundary_nodes_to_contours`) **solo** se ejecuta en +`_on_new_project` (wizard de creación). Nunca en `_on_offsets_edited_from_viewer` ni en +`Hull.from_dict()`. Los `x_offsets` son datos del usuario y se restauran tal cual. + +--- + +## Sentinels de nodo especial (`viewer_lines.py`) + +```python +_KEEL_IDX = -1 # nodo de quilla (keel_z[i] por estación) +_SHEER_IDX = -2 # nodo de cubierta (sheer_z[i] por estación) +_STEM_IDX = -10 # punto de control de roda +_TRANS_IDX = -20 # punto de control de espejo de popa +``` + +El índice `j` en `(i, j)` siendo negativo indica nodo especial, no columna de `data[i,j]`. + +--- + +## Módulo 1 — Geometría del Casco + +**Archivo clave:** `arshipdesign/core/hull.py`, `arshipdesign/core/offsets.py` + +### Estructura de datos + +``` +Hull +├── offsets: OffsetsTable +│ ├── x_stations[n_sta] — posición X de cada estación [m] +│ ├── data[n_sta, n_wl] — semi-manga Y por (estación, LdA) [m] +│ ├── keel_z[n_sta] — Z de la quilla por estación [m] +│ ├── z_waterlines[n_wl] — Z absoluta de cada LdA [m] +│ ├── z_offsets[n_sta, n_wl] — ajuste Z local por nodo [m] +│ └── x_offsets[n_sta, n_wl] — ajuste X visual del nodo en los visores 2D [m] +├── sheer_z[n_sta] — Z de la cubierta (arrufo) por estación [m] +├── stem_ctrl[k, 2] — polígono de control de la roda (B-spline) +├── transom_ctrl[k, 2] — polígono de control del espejo de popa +└── corner_nodes: list[[i,j]] — nodos marcados como esquina (rompen suavidad) +``` + +### Estado ✅ + +- **Serialización** (`to_dict` / `from_dict`): guarda todos los arrays sin recalcular. + Al cargar, los `x_offsets` se restauran exactamente como el usuario los dejó. +- **Inserción de estaciones** (`insert_station`): interpola Y, keel_z, sheer_z y offsets. +- **Inserción de líneas de agua** (`insert_waterline`): interpola semi-mangas. +- **B-Spline de sección** (`_section_yz` en `to_mesh`): muestrea el perfil Y-Z + desde quilla → LdA de control → cubierta con grado mín(3, n-1). +- **Malla 3D** (`to_mesh`): grilla estructurada n_u × n_v interpolada entre estaciones, + triangulada para PyVista. Genera ambas bandas (estribor + babor). +- **Lazy cache** (`station_planes`, `get_sheer_z`): no recalcula si los datos no cambian. + +### Bug conocido 🐛 + +**"Tabla en quilla"** — Si se mueve `keel_z[i]` de una sola estación muy lejos de +las vecinas, la malla 3D muestra una depresión abrupta (tabla/aleta) porque: +- Las LdA permanecen en sus Z fijos absolutas. +- La interpolación entre estaciones crea una concavidad estrecha en esa estación. +- **Workaround:** mover la quilla en varias estaciones sucesivas para distribuir el cambio. +- **Fix definitivo:** wizard de redistribución de LdA + más puntos de control de quilla. + +--- + +## Módulo 2 — Visores 2D Interactivos + +**Archivo clave:** `arshipdesign/ui/widgets/viewer_lines.py` + +### Clases principales + +``` +_BaseViewer — zoom, paneo, drag de nodos, hit-test, HUD, fairness, selección de curva +├── BodyPlanViewer — secciones transversales Y-Z (cuadernas) +├── ProfileViewer — vista lateral X-Z (quilla, cubierta, roda, espejo) +└── PlanViewer — vista de planta X-Y (líneas de agua desde arriba) +``` + +### Estado 🔧 + +- **Drag de nodos**: todos los nodos arrastrables, sin restricciones (respeta Regla de Ejes). +- **Selección de nodo** (clic): nodo se vuelve dorado; panel `NodeInfoPanel` muestra X/Y/Z + y checkbox de esquina. Enter aplica el valor editado manualmente. +- **Selección de curva** (Shift+clic): detecta arista de la malla NURBS más cercana. + La curva completa se resalta en verde menta `#00FFB0` con 2.5 px. + - Body Plan: Shift+clic → sección completa keel→LdA→sheer (estación i) + - Perfil: Shift+clic → quilla o cubierta (curva longitudinal) + - Planta: Shift+clic → línea de agua j completa +- **Peines de curvatura** `[C]`: pelos perpendiculares a la curva. + Normalizados por max|κ| → siempre visibles aunque la curva sea casi recta. + Solo en la curva seleccionada (Shift+clic) o en todas si no hay selección. + Pelo invertido al lado opuesto = inflexión (cambio de signo de curvatura). +- **Coloreo de equidad** `[F]`: nodos coloreados verde→amarillo→rojo por |d²Y/dX²|. +- **Suavizado local** `[S]`: Laplaciano 1 paso en el nodo seleccionado. +- **Zoom**: rueda del ratón. Doble clic: fit-to-view. +- **Paneo**: botón medio o derecho + arrastrar. +- **HUD** (esquina inferior derecha): estado de [C]/[F]/[S] y nombre de la curva activa. +- **Sincronización entre vistas** (en vivo): `offsets_dragging` durante el drag, + `offsets_edited` al soltar. +- **Menú contextual** (clic derecho): insertar LdA, estación, roda, espejo, esquina. + +### Historial de correcciones + +| Fecha | Problema | Causa raíz | Fix aplicado | +|-------|----------|------------|--------------| +| 2026-05-28 | Nodos de borde no arrastrables en X | `_hit_test` de ProfileViewer excluía i=0 e i=n-1 para LdA normales | Revertido: loop incluye todos los nodos sin excepción | +| 2026-05-28 | Peines de curvatura invisibles | `scale = beam × 0.20` → κ≈0.02 → pelo de 2cm, invisible a escala normal | Normalizado: todos los κ ÷ max\|κ\| antes de escalar | +| 2026-05-28 | `self._selected` no existe | Nombre incorrecto del atributo | Corregido a `self._selected_idx` | + +### Pendiente 📋 + +- Peines de curvatura en keel/sheer desde el ProfileViewer (actualmente solo en quilla/cubierta como curvas, no como Z). +- Suavizado 2D (Laplaciano transversal dentro de la cuaderna). +- Tests automatizados para fairness coloring y suavizado. + +--- + +## Módulo 3 — Visor 3D + +**Archivo clave:** `arshipdesign/ui/widgets/viewer_3d.py` + +### Estado 🔧 + +- **Motor**: PyVista + pyvistaqt (`QtInteractor` embebido). +- **Degradación sin PyVista**: muestra `QLabel` en lugar de crashear (permite que CI pase). +- **Carga diferida**: `QtInteractor` se crea 500 ms después del arranque (evita conflicto OpenGL). +- **Tema oscuro**: fondo `#1a1d30`, casco `#3a6080`, aristas `#4da8ff`, plano de flotación `#4da8ff` al 15%. +- **Toggle mallas** (botón `⬡ Mallas` en barra superior del visor): apagado por defecto. + Llama `GetProperty().EdgeVisibilityOn/Off()` sobre el actor VTK → sin re-render. + +### Historial de correcciones + +| Fecha | Problema | Fix | +|-------|----------|-----| +| 2026-05-29 | Mallas siempre visibles, sin forma de apagarlas | Añadido botón toggle + `_show_edges=False` por defecto | + +### Pendiente 📋 + +- Caras invertidas: detectar y colorear diferente (rojo/azul), comando flip. +- Capas de visualización: buttocks, waterlines, sections como actores independientes. +- Cierre de malla en AP para transom stern. + +--- + +## Módulo 4 — Guardado y Cargado de Proyectos + +**Archivos:** `arshipdesign/core/project.py`, `arshipdesign/core/hull.py` + +### Formato `.arsd` + +Archivo ZIP que contiene `hull.json` con formato `hull_v1`. +Incluye todos los arrays de offsets, control curves, y metadatos del buque. + +### Estado ✅ + +- **Persistencia exacta**: todos los arrays se guardan y restauran fielmente. +- **Sin snap en carga**: `from_dict` no llama `snap_boundary_nodes_to_contours`. + +### Historial de correcciones + +| Fecha | Problema | Causa | Fix | +|-------|----------|-------|-----| +| 2026-05-28 | Forma diferente al recargar | `snap_boundary_nodes_to_contours` en `from_dict` recalculaba `x_offsets` | Eliminado de `from_dict` | +| 2026-05-28 | Nodos saltaban al soltar | `snap` en `_on_offsets_edited_from_viewer` sobreescribía la posición del usuario | Eliminado del handler | + +--- + +## Módulo 5 — Hidrostáticos + +**Archivos:** `arshipdesign/core/hydrostatics.py` + +### Estado ✅ + +- Cálculo en tiempo real al modificar cualquier nodo. +- Métricas: Δ, LCB, TCB, KB, BM, GM, Cb, Cm, Cp, Cw, AWP. +- Validado contra casco analítico Wigley (IACS Rec.34 §4). Tests: 315/315 ✅ + +--- + +## Módulo 6 — Estabilidad + +**Archivo:** `arshipdesign/core/stability.py` + +### Estado ✅ + +- Curva GZ por planos de inclinación. +- Criterios IMO IS Code 2008 verificados. + +--- + +## Módulo 7 — Generadores Paramétricos + +**Archivos:** `arshipdesign/parametric/wizard_*.py` + +### Familias disponibles ✅ + +| Familia | Archivo | Estado | +|---------|---------|--------| +| Workboat (buque de trabajo) | `wizard_workboat.py` | ✅ | +| Velero | `wizard_sailing.py` | ✅ | +| Lancha rápida | `wizard_fast.py` | ✅ | +| Remolcador | `wizard_tug.py` | ✅ | +| Ferry / pasaje | `wizard_ferry.py` | ✅ | + +- **Arrufo parabólico**: `sheer_z[i] = sheer_base + camber × (1 − (2x/L − 1)²)` +- Snap de nodos de contorno se aplica **una sola vez** al crear el proyecto. + +### Pendiente 📋 + +- Opción transom stern en el wizard (`has_transom: bool`, `transom_angle: float`). +- Wizard de estaciones/LdA/buttocks: definir manualmente posiciones antes de generar la malla. + +--- + +## Módulo 8 — UI / Layout / Ribbon + +**Archivos:** `arshipdesign/ui/main_window.py`, widgets varios + +### Estado 🔧 + +- **Layout 4 viewports**: QSplitters anidados. Arriba: 3D+Perfil. Abajo: FrontalI+Planta. +- **Maximizar viewport** (botón `⬜`/`❎` o doble clic en barra de título): + oculta viewport compañero y fila opuesta. Restaurar vuelve a 50/50. +- **Ribbon**: tabs Geometría, Hidrostáticos, Estabilidad, Estructural. + Grupo "Suavizado" con botones Curvatura, Equidad, Suavizar. +- **NodeInfoPanel**: flotante, coordenadas X/Y/Z editables + checkbox esquina. + +### Historial de correcciones + +| Fecha | Problema | Fix | +|-------|----------|-----| +| 2026-05-28 | Enter en NodeInfoPanel no aplicaba cambio | Señal `coord_edited` no conectada | Conectada en `__init__` | +| 2026-05-29 | `QPushButton` no importado | Faltaba en bloque de imports | Añadido | + +--- + +## Módulo 9 — Herramientas de Fairness (Equidad) + +**Funciones en** `viewer_lines.py`: `_fairness_color`, `_smooth_selected_node`, +`_draw_curvature_comb`, `_curvature_comb_data`, `_dist_to_segment` + +### Peines de curvatura + +``` +κᵢ = 2 × cross(t₁, t₂) / (l₁ + l₂) — curvatura discreta firmada +κ_normalizada = κᵢ / max|κ| — rango [-1, 1] +pelo_longitud = κ_normalizada × scale — en unidades de mundo +``` + +- Pelo al lado contrario de la curva = curvatura positiva (convexa). +- Pelo al mismo lado = curvatura negativa (cóncava / inflexión). +- Spine = línea que une las puntas → revela continuidad de curvatura. + +### Coloreo de equidad + +``` +roughness = |Y[i+1] - 2·Y[i] + Y[i-1]| / (Δx²) +``` +- Verde `#22cc66`: roughness < 0.005 m⁻¹ +- Rojo `#e03030`: roughness > 0.150 m⁻¹ + +### Suavizado Laplaciano 1-paso + +``` +Y_new[i] = (Y[i-1] + Y[i] + Y[i+1]) / 3 +``` +Solo nodos interiores. Aplica a Y breadths, keel_z y sheer_z. + +--- + +## Módulo 10 — Deshacer / Rehacer (Ctrl+Z / Ctrl+Y) + +**Archivo:** `arshipdesign/ui/main_window.py` + +### Estado 🔧 + +- **Mecanismo**: stack de snapshots `hull.to_dict()` — cada estado es una copia completa del casco serializado (arreglos numpy → listas, muy pequeño en memoria). +- **Capacidad**: 50 pasos de deshacer (`_MAX_UNDO = 50`). +- **Ctrl+Z** (`Editar → Deshacer`): restaura el estado anterior al último drag/edición. +- **Ctrl+Y** (`Editar → Rehacer`): rehace el cambio deshecho. +- Cada nueva edición **limpia el stack de redo** (rama nueva invalida el futuro). +- Al crear o abrir un proyecto, ambos stacks se limpian (`_reset_undo_history`). +- Las acciones del menú se habilitan/deshabilitan según haya pasos disponibles. + +### Cómo funciona internamente + +``` +_last_hull_state = snapshot del hull ANTES del último edit +_undo_stack = [estado_0, estado_1, ..., estado_n] ← el más reciente al final +_redo_stack = estados deshechados disponibles + +Al recibir offsets_edited: + 1. push _last_hull_state → _undo_stack + 2. clear _redo_stack + 3. _last_hull_state = hull.to_dict() (nuevo estado actual) + +Al hacer Ctrl+Z: + 1. push hull.to_dict() → _redo_stack + 2. hull = Hull.from_dict(_undo_stack.pop()) + 3. _load_hull_viewers(hull) — refresca todos los visores + hidrostáticos +``` + +### Qué operaciones son deshaciibles + +| Operación | ¿Deshacible? | +|-----------|-------------| +| Arrastrar nodo | ✅ | +| Suavizar con [S] | ✅ (si emite offsets_edited) | +| Editar coordenada en panel | ✅ | +| Insertar estación/LdA desde menú contextual | ✅ | +| Crear nuevo proyecto | ❌ (limpia el historial) | +| Abrir proyecto | ❌ (limpia el historial) | + +--- + +## Módulo 11 — Iconos de Ribbon (arshipdesign/ui/icons.py) + +**Estado:** 🔧 Implementado — pendiente verificación visual + +### Qué hace + +Nuevo módulo `arshipdesign/ui/icons.py` con **50 iconos programáticos** únicos, uno por cada +botón del ribbon. Antes todos compartían el mismo icono genérico del sistema (`SP_FileDialogDetailedView`). + +### Diseño técnico + +- Cada icono se dibuja con `QPainter` sobre un `QPixmap(24×24)` transparente. +- Paleta coherente con el tema oscuro: + - `#c8d8e8` trazo principal + - `#4da8ff` cyan / agua + - `#00ffb0` verde mint (selección / OK) + - `#ffd060` amarillo / dorado (energía, controles) + - `#ff5555` rojo (daño, alerta) +- Función pública: `icon("clave") → QIcon` con caché `_CACHE` dict. +- Importado en `main_window.py` como `from arshipdesign.ui.icons import icon as _ico`. + +### Iconos implementados por grupo + +| Grupo | Claves | +|-------|--------| +| HOME / Vistas | `4views`, `lines_plan` | +| Geometría / Nuevo | `wizard`, `hull_nurbs`, `appendage` | +| Geometría / Edición NURBS | `ctrl_pts`, `extrude`, `mirror`, `lackenby` | +| Geometría / Importar | `import_offsets`, `import_dxf` | +| Geometría / Exportar | `export_iges`, `export_step`, `export_dxf` | +| Geometría / Suavizado | `smooth`, `combs`, `fairness` | +| Análisis / Hidrostática | `hydro_calc`, `hydro_curves`, `export_csv` | +| Análisis / Estabilidad | `gz_curve`, `imo`, `damage` | +| Análisis / Resistencia | `holtrop`, `savitsky`, `vpp` | +| Análisis / Seakeeping | `stf`, `spectrum` | +| Análisis / Estructura | `iso12215` | +| Tanques | `new_tank`, `model_tank`, `load_case`, `sounding`, `calc_kg` | +| Sistemas / Eléctrico | `epla` | +| Sistemas / Fluidos | `fuel`, `freshwater`, `bilge`, `firefight` | +| Sistemas / Routing 3D | `pipes`, `cables` | +| Sistemas / Clima | `hvac`, `steering` | +| Fabricación / CNC | `materials`, `nesting`, `gcode`, `postproc` | +| Fabricación / Moldes FRP | `lofting`, `laminate`, `resin`, `bom` | + +### Decisiones + +- Se mantienen los iconos estándar del sistema para: Nuevo, Abrir, Guardar (Archivo), + Deshacer/Rehacer (flechas del sistema), Offsets (vista de lista). +- El módulo NO importa Qt en el nivel de módulo — los QIcon solo se crean cuando se llaman, + así la importación de `icons.py` es segura antes de que exista `QApplication`. + +### Corrección — Rediseño v2 (2026-05-30 sesión 2) + +**Problema detectado:** La primera versión usaba trazos claros (`#c8d8e8`) sobre fondo +transparente. El ribbon de PySide6 tiene fondo blanco → los iconos eran prácticamente +invisibles (se veía solo el borde del botón). + +**Solución:** Rediseño completo con estilo "flat icon": +- Relleno sólido de color por categoría + contorno oscuro `#1a2535` +- Visible en fondos claros Y oscuros +- Colores por categoría: + +| Categoría | Color | +|-----------|-------| +| Geometría / Casco | Azul océano `#2a7fc8` | +| Edición NURBS | Índigo `#5548d0` | +| Suavizado | Verde vivo `#20a860` | +| Peines | Púrpura `#7040c8` + verde mint | +| Fairness | Gradiente rojo→verde | +| Análisis hidro | Teal `#1898a8` | +| Estabilidad | Azul `#2068c0` | +| Resistencia | Naranja `#d07020` | +| Tanques | Cyan `#18a0c0` | +| Sistemas eléctrico | Amarillo sobre negro | +| Fabricación | Violeta `#8838b8` | + +**Estado tras rediseño:** 🔧 Pendiente verificar visualmente (requiere `python main.py`) + +--- + +## Módulo 12 — Peines de Curvatura Mejorados + +**Estado:** 🔧 Implementado — pendiente verificación visual + +### Cambios en `viewer_lines.py` + +**Problema:** Los peines se dibujaban usando los ~10-20 puntos crudos de la tabla de +offsets. Resultado: pelos escasos, ángulos bruscos, spine anguloso. + +**Solución:** Nueva función `_resample_curve_smooth(xs, ys, n=80)`: +- Parametriza la curva por longitud de arco acumulada +- Remuestrea a **80 puntos equidistantes** usando `scipy.interpolate.CubicSpline` +- Fallback a `np.interp` (lineal) si scipy no está disponible +- Llamada al inicio de `_draw_curvature_comb` antes de calcular κ + +**Resultado esperado:** 80 pelos por curva en lugar de ~10-20, spine suave. + +### Regla +No aumentar más de 80 muestras sin medir impacto en FPS — la función se llama en +cada `paintEvent` (puede ser frecuente al arrastrar nodos). + +--- + +## Módulo 13 — Visor 3D Colores Sólidos + +**Estado:** 🔧 Implementado — pendiente verificación visual + +### Cambios en `viewer_3d.py` — `_render_hull_mesh` + +| Parámetro | Antes | Ahora | Por qué | +|-----------|-------|-------|---------| +| `smooth_shading` | `True` | `False` | Facetas planas = aspecto sólido, sin blur | +| `opacity` | `0.92` | `1.0` | Totalmente opaco = color pleno | +| `ambient` | (default ~0.2) | `0.40` | Reduce sombras duras, color más uniforme | +| `diffuse` | (default ~0.8) | `0.60` | Equilibrio iluminación | +| `specular` | (default ~0.1) | `0.05` | Sin brillos que difuminen | +| `color` | `#3a6080` | `#4a8ab0` | Tono más vivo y legible | +| `line_width` | `0.3` | `0.6` | Aristas más visibles al activar mallas | + +### Nota +Si en el futuro se quiere smooth shading selectivo (solo en alta resolución), +usar `mesh.compute_normals()` primero y luego `smooth_shading=True`. + +--- + +## Módulo 14 — Fix Freeze Curva GZ (QThread) + +**Estado:** 🔧 Implementado — pendiente verificación + +### Problema + +`_on_show_stability` → `_compute_and_show_gz` → `compute_gz_wall_sided` → +`compute_upright` (integración hidrostática pesada) → **bloqueaba el UI thread de Qt** +indefinidamente ("se trabó el programa"). + +### Solución + +Nueva clase `_GZWorker(QObject)` con señales `finished` / `error`. +El cálculo se mueve a un `QThread`: + +``` +[UI thread] botón → _compute_and_show_gz() + ↓ lanza QThread +[Hilo GZ] _GZWorker.run() + ↓ emite finished(gz_curve, imo_result) +[UI thread] _on_gz_done() → actualiza widget + statusBar +``` + +### Guarda doble + +Si el usuario hace clic dos veces seguidas, el segundo clic se ignora mientras el +hilo anterior sigue corriendo (`if self._gz_thread.isRunning(): return`). + +### Archivos modificados + +| Archivo | Cambio | +|---------|--------| +| `main_window.py` | Import `QThread, QObject`; clase `_GZWorker`; `_compute_and_show_gz` refactorizado; nuevo slot `_on_gz_done` | + +--- + +## Roadmap Global + +| Prioridad | Feature | Módulo | Estado | +|-----------|---------|--------|--------| +| 🔴 Alta | Wizard de estaciones / LdA / buttocks | Geometría | 📋 | +| 🔴 Alta | Transom stern (popa espejo) | Geometría + 3D | 📋 | +| 🟡 Media | Verificar iconos ribbon visualmente | UI | 🔧 | +| 🟡 Media | Verificar peines densidad visual | Fairness | 🔧 | +| 🟡 Media | Verificar colores sólidos 3D visual | Visor 3D | 🔧 | +| 🟡 Media | Caras invertidas 3D + flip | Visor 3D | 📋 | +| 🟡 Media | Peines de curvatura en keel/sheer (Z) | Fairness | 📋 | +| 🟡 Media | Suavizado 2D (Laplaciano transversal) | Fairness | 📋 | +| 🟢 Baja | Tests de fairness automatizados | Tests | 📋 | +| 🟢 Baja | Exportar DXF / offsets CSV | Exportación | 📋 | +| 🟢 Baja | Importar offsets desde tabla manual | Importación | 📋 | + +--- + +## Tests y Entorno + +```bash +# Ejecutar suite completa +cd "D:\Proyectos Software\AR-Shipdesign" +python -m pytest tests/ -x -q + +# Lanzar la aplicación +python main.py +``` + +**Estado de tests:** 315/315 ✅ — Última verificación: 2026-05-30 +⚠️ Tests no actualizados para GZWorker (QThread) — agregar en próxima sesión. + +--- + +*Última actualización: 2026-05-30 (sesión 2)* +*Mantener este archivo actualizado al final de cada sesión de trabajo.*