1042 lines
33 KiB
Python
1042 lines
33 KiB
Python
# Copyright (C) 2026 Antoine ANCEAU
|
|
#
|
|
# This file is part of navaltoolbox.
|
|
#
|
|
# navaltoolbox is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
"""
|
|
Visualization module for navaltoolbox using Plotly.
|
|
"""
|
|
|
|
from typing import Optional, List, Tuple, Any
|
|
import numpy as np
|
|
import plotly.graph_objects as go # type: ignore
|
|
|
|
from . import Vessel, Hull
|
|
|
|
|
|
def plot_vessel_3d(
|
|
vessel: Vessel,
|
|
show_hulls: bool = True,
|
|
show_tanks: bool = True,
|
|
show_silhouettes: bool = True,
|
|
show_openings: bool = True,
|
|
show_appendages: bool = True,
|
|
show_deck_edges: bool = True,
|
|
opacity_hull: float = 0.5,
|
|
opacity_tank: float = 0.3,
|
|
opacity_appendage: float = 0.5,
|
|
title: str = "Vessel Visualization",
|
|
enable_opacity_slider: bool = True,
|
|
show_axes: bool = True,
|
|
) -> go.Figure:
|
|
"""
|
|
Create an interactive 3D plot of the vessel.
|
|
|
|
Args:
|
|
vessel: The vessel to visualize.
|
|
show_hulls: Whether to show hull meshes.
|
|
show_tanks: Whether to show tank meshes.
|
|
show_silhouettes: Whether to show silhouette profiles.
|
|
show_openings: Whether to show downflooding openings.
|
|
show_appendages: Whether to show appendages.
|
|
show_deck_edges: Whether to show deck edges.
|
|
opacity_hull: Opacity for hull meshes (0.0 to 1.0).
|
|
opacity_tank: Opacity for tank meshes (0.0 to 1.0).
|
|
opacity_appendage: Opacity for appendage meshes (0.0 to 1.0).
|
|
title: Title of the plot.
|
|
enable_opacity_slider: Whether to add a slider to control hull opacity.
|
|
show_axes: Whether to show axes, grid, and background.
|
|
|
|
Returns:
|
|
A Plotly Figure object.
|
|
"""
|
|
fig = go.Figure()
|
|
|
|
hull_indices = []
|
|
|
|
# 1. Hulls
|
|
if show_hulls:
|
|
for i, hull in enumerate(vessel.get_hulls()):
|
|
_add_hull_trace(fig, hull, f"Hull {i+1}", opacity_hull)
|
|
# Track the index of the added trace
|
|
hull_indices.append(len(fig.data) - 1)
|
|
|
|
# 2. Tanks
|
|
if show_tanks:
|
|
for tank in vessel.get_tanks():
|
|
# Tank Container (Wireframe or transparent mesh)
|
|
if hasattr(tank, "get_vertices") and hasattr(tank, "get_faces"):
|
|
verts = tank.get_vertices()
|
|
faces = tank.get_faces()
|
|
|
|
if verts and faces:
|
|
x, y, z = zip(*verts)
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="lightgray",
|
|
opacity=opacity_tank,
|
|
name=f"{tank.name} (Tank)",
|
|
showscale=False,
|
|
alphahull=0, # Use exact mesh
|
|
)
|
|
)
|
|
|
|
# Fluid Volume (Higher opacity)
|
|
if hasattr(tank, "get_fluid_vertices") and hasattr(
|
|
tank, "get_fluid_faces"
|
|
):
|
|
f_verts = tank.get_fluid_vertices()
|
|
f_faces = tank.get_fluid_faces()
|
|
|
|
if f_verts and f_faces:
|
|
fx, fy, fz = zip(*f_verts)
|
|
fi, fj, fk = zip(*f_faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=fx,
|
|
y=fy,
|
|
z=fz,
|
|
i=fi,
|
|
j=fj,
|
|
k=fk,
|
|
color="blue",
|
|
opacity=min(opacity_tank * 2.0, 0.9),
|
|
name=f"{tank.name} (Fluid)",
|
|
showscale=False,
|
|
)
|
|
)
|
|
|
|
# Keep COG marker for reference
|
|
cog = tank.center_of_gravity
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[cog[0]],
|
|
y=[cog[1]],
|
|
z=[cog[2]],
|
|
mode="markers",
|
|
marker=dict(size=3, color="black"),
|
|
name=f"{tank.name} COG",
|
|
showlegend=False,
|
|
)
|
|
)
|
|
|
|
# 3. Silhouettes (Lines)
|
|
if show_silhouettes:
|
|
for sil in vessel.get_silhouettes():
|
|
points = sil.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
x, y, z = zip(*points)
|
|
# Close the loop if needed
|
|
if sil.is_closed() and (x[0] != x[-1] or z[0] != z[-1]):
|
|
x = x + (x[0],)
|
|
y = y + (y[0],)
|
|
z = z + (z[0],)
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
mode="lines",
|
|
line=dict(color="green", width=2),
|
|
name=f"Silhouette: {sil.name}",
|
|
)
|
|
)
|
|
|
|
# 4. Openings (Markers)
|
|
if show_openings:
|
|
for opening in vessel.get_openings():
|
|
points = opening.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
x, y, z = zip(*points)
|
|
|
|
# If contour, draw lines
|
|
if len(points) > 1:
|
|
# Close loop
|
|
x = x + (x[0],)
|
|
y = y + (y[0],)
|
|
z = z + (z[0],)
|
|
mode = "lines+markers"
|
|
else:
|
|
mode = "markers"
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
mode=mode,
|
|
marker=dict(size=4, color="red"),
|
|
line=dict(color="red", width=2),
|
|
name=f"Opening: {opening.name}",
|
|
)
|
|
)
|
|
|
|
# 5. Appendages
|
|
if show_appendages:
|
|
for app in vessel.get_appendages():
|
|
_add_appendage_trace(fig, app, opacity_appendage)
|
|
|
|
# 6. Deck Edges
|
|
if show_deck_edges:
|
|
for edge in vessel.get_deck_edges():
|
|
points = edge.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
x, y, z = zip(*points)
|
|
side = edge.get_side()
|
|
|
|
# Determine color based on side
|
|
color = "orange" # Default/Both
|
|
if "Port" in side:
|
|
color = "red"
|
|
elif "Starboard" in side:
|
|
color = "green"
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
mode="lines+markers",
|
|
line=dict(color=color, width=4),
|
|
marker=dict(size=3, color=color),
|
|
name=f"Deck Edge: {edge.name} ({side})",
|
|
)
|
|
)
|
|
|
|
# 5. Reference Points (AP/FP)
|
|
# Using Y=0, Z=0 for reference
|
|
ap_x = vessel.ap
|
|
fp_x = vessel.fp
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[ap_x, fp_x],
|
|
y=[0, 0],
|
|
z=[0, 0],
|
|
mode="markers+text",
|
|
marker=dict(size=5, color="black", symbol="diamond"),
|
|
text=["AP", "FP"],
|
|
textposition="top center",
|
|
name="Perpendiculars",
|
|
)
|
|
)
|
|
|
|
# Set axis labels and equal aspect ratio
|
|
bounds = vessel.get_bounds()
|
|
max_range = max(
|
|
bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4]
|
|
)
|
|
center_x = (bounds[0] + bounds[1]) / 2
|
|
center_y = (bounds[2] + bounds[3]) / 2
|
|
center_z = (bounds[4] + bounds[5]) / 2
|
|
|
|
# Hack to force aspect ratio in Plotly
|
|
# We add invisible points to set the range
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[center_x - max_range / 2, center_x + max_range / 2],
|
|
y=[center_y - max_range / 2, center_y + max_range / 2],
|
|
z=[center_z - max_range / 2, center_z + max_range / 2],
|
|
mode="markers",
|
|
marker=dict(size=0, opacity=0),
|
|
showlegend=False,
|
|
)
|
|
)
|
|
|
|
# Sliders
|
|
sliders = []
|
|
if enable_opacity_slider and hull_indices:
|
|
steps = []
|
|
for op in np.arange(0, 1.1, 0.1):
|
|
step = dict(
|
|
method="restyle",
|
|
args=[{"opacity": op}, hull_indices],
|
|
label=f"{op:.1f}",
|
|
)
|
|
steps.append(step)
|
|
|
|
sliders.append(
|
|
dict(
|
|
active=int(opacity_hull * 10),
|
|
currentvalue={"prefix": "Hull Opacity: "},
|
|
pad={"t": 50},
|
|
steps=steps,
|
|
)
|
|
)
|
|
|
|
scene_args: dict[str, Any] = dict(aspectmode="cube")
|
|
|
|
if show_axes:
|
|
scene_args.update(
|
|
dict(
|
|
xaxis_title="X (Longitudinal)",
|
|
yaxis_title="Y (Transverse)",
|
|
zaxis_title="Z (Vertical)",
|
|
)
|
|
)
|
|
else:
|
|
scene_args.update(
|
|
dict(
|
|
xaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
yaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
zaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
title=title,
|
|
scene=scene_args,
|
|
sliders=sliders,
|
|
updatemenus=[
|
|
# 1. Projection (Left)
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
buttons=[
|
|
dict(
|
|
label="Persp 👁️",
|
|
method="relayout",
|
|
args=["scene.camera.projection.type", "perspective"],
|
|
),
|
|
dict(
|
|
label="Ortho 📐",
|
|
method="relayout",
|
|
args=["scene.camera.projection.type", "orthographic"],
|
|
),
|
|
],
|
|
pad={"r": 5, "t": 5},
|
|
showactive=True,
|
|
x=0.0,
|
|
xanchor="left",
|
|
y=1.1,
|
|
yanchor="top",
|
|
bgcolor="rgba(255, 255, 255, 0.7)",
|
|
),
|
|
# 2. Views (Right, with gap)
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
buttons=_get_camera_buttons(),
|
|
pad={"r": 5, "t": 5},
|
|
showactive=True,
|
|
x=0.25, # Gap from projection buttons
|
|
xanchor="left",
|
|
y=1.1,
|
|
yanchor="top",
|
|
bgcolor="rgba(255, 255, 255, 0.7)",
|
|
),
|
|
],
|
|
)
|
|
|
|
return fig
|
|
|
|
|
|
def plot_hydrostatic_condition(
|
|
vessel: Vessel,
|
|
draft: float,
|
|
trim: float = 0.0,
|
|
heel: float = 0.0,
|
|
show_hulls: bool = True,
|
|
show_tanks: bool = True,
|
|
show_silhouettes: bool = True,
|
|
show_openings: bool = True,
|
|
show_appendages: bool = True,
|
|
show_deck_edges: bool = True,
|
|
opacity_hull: float = 0.5,
|
|
opacity_tank: float = 0.3,
|
|
opacity_appendage: float = 0.5,
|
|
title: str = "Hydrostatic Condition",
|
|
enable_opacity_slider: bool = True,
|
|
cog: Optional[Tuple[float, float, float]] = None,
|
|
show_axes: bool = True,
|
|
) -> go.Figure:
|
|
"""
|
|
Visualize the vessel at a specific hydrostatic condition (floating in
|
|
water).
|
|
The waterplane is fixed at Z=0.
|
|
Vessel is transformed to match drafts.
|
|
|
|
Args:
|
|
vessel: The vessel to visualize.
|
|
draft: Draft at midship (or reference point) in meters.
|
|
trim: Trim angle in degrees (positive = stern down usually).
|
|
heel: Heel angle in degrees (positive = starboard down usually).
|
|
show_hulls: Whether to show hull meshes.
|
|
show_tanks: Whether to show tank meshes.
|
|
show_silhouettes: Whether to show silhouette profiles.
|
|
show_openings: Whether to show downflooding openings.
|
|
show_appendages: Whether to show appendages.
|
|
show_deck_edges: Whether to show deck edges.
|
|
opacity_hull: Opacity for hull meshes (0.0 to 1.0).
|
|
opacity_tank: Opacity for tank meshes (0.0 to 1.0).
|
|
opacity_appendage: Opacity for appendage meshes (0.0 to 1.0).
|
|
title: Title of the plot.
|
|
enable_opacity_slider: Whether to add a slider to control hull opacity.
|
|
cog: Optional Center of Gravity (x, y, z) to display.
|
|
show_axes: Whether to show axes, grid, and background.
|
|
"""
|
|
fig = go.Figure()
|
|
hull_indices = []
|
|
|
|
# Calculate Transformation Matrix
|
|
# We want to move Ship -> World such that water is at Z=0.
|
|
# Rotation Order: Pitch (Trim) then Roll (Heel).
|
|
# R = Ry(trim) * Rx(heel)
|
|
|
|
h_rad = np.deg2rad(heel)
|
|
t_rad = np.deg2rad(trim)
|
|
|
|
ch, sh = np.cos(h_rad), np.sin(h_rad)
|
|
ct, st = np.cos(t_rad), np.sin(t_rad)
|
|
|
|
# Rx (Heel around X)
|
|
Rx = np.array([[1, 0, 0], [0, ch, -sh], [0, sh, ch]])
|
|
|
|
# Ry (Trim around Y)
|
|
Ry = np.array([[ct, 0, st], [0, 1, 0], [-st, 0, ct]])
|
|
|
|
# Combined Rotation R (Heel then Trim, to match Rust: rot_y * rot_x)
|
|
R_total = Ry @ Rx
|
|
|
|
# Pivot point in Rust core is bounding box center and draft
|
|
bounds = vessel.get_bounds()
|
|
center_x = (bounds[0] + bounds[1]) / 2.0
|
|
center_y = (bounds[2] + bounds[3]) / 2.0
|
|
|
|
pivot_rust = np.array([center_x, center_y, draft])
|
|
offset_vis = np.array([center_x, center_y, 0.0])
|
|
|
|
# Transform function
|
|
def transform_points(points):
|
|
if len(points) == 0:
|
|
return []
|
|
pts = np.array(points)
|
|
|
|
# Shift to pivot as defined in Rust core
|
|
shifted = pts - pivot_rust
|
|
|
|
# Apply rotation
|
|
rotated = shifted @ R_total.T
|
|
|
|
# Shift back and apply waterplane translation
|
|
# (Rust waterplane is at Z=draft, Viz waterplane is at Z=0)
|
|
translated = rotated + offset_vis
|
|
return translated
|
|
|
|
# 0. Waterplane
|
|
# Big plane
|
|
L = bounds[1] - bounds[0]
|
|
B = bounds[3] - bounds[2]
|
|
plane_size = max(L, B) * 1.5
|
|
|
|
# Grid of points for water
|
|
wx = np.linspace(-plane_size / 2, plane_size / 2, 2) + (
|
|
bounds[0] + bounds[1]
|
|
) / 2
|
|
wy = np.linspace(-plane_size / 2, plane_size / 2, 2)
|
|
WX, WY = np.meshgrid(wx, wy)
|
|
WZ = np.zeros_like(WX)
|
|
|
|
fig.add_trace(
|
|
go.Surface(
|
|
x=WX,
|
|
y=WY,
|
|
z=WZ,
|
|
opacity=0.3,
|
|
colorscale=[[0, "blue"], [1, "blue"]],
|
|
showscale=False,
|
|
name="Waterplane",
|
|
)
|
|
)
|
|
|
|
# Add waterplane outline (so it's visible in orthographic side views)
|
|
cx = (bounds[0] + bounds[1]) / 2
|
|
cy = 0
|
|
half_size = plane_size / 2
|
|
|
|
# Square aligned with axes
|
|
outline_x = [
|
|
cx - half_size,
|
|
cx + half_size,
|
|
cx + half_size,
|
|
cx - half_size,
|
|
cx - half_size,
|
|
]
|
|
outline_y = [
|
|
cy - half_size,
|
|
cy - half_size,
|
|
cy + half_size,
|
|
cy + half_size,
|
|
cy - half_size,
|
|
]
|
|
outline_z = [0, 0, 0, 0, 0]
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=outline_x,
|
|
y=outline_y,
|
|
z=outline_z,
|
|
mode="lines",
|
|
line=dict(color="blue", width=4),
|
|
name="Waterline Level",
|
|
)
|
|
)
|
|
|
|
# 1. Hulls
|
|
if show_hulls:
|
|
for i, hull in enumerate(vessel.get_hulls()):
|
|
if hasattr(hull, "get_vertices") and hasattr(hull, "get_faces"):
|
|
verts = hull.get_vertices()
|
|
faces = hull.get_faces()
|
|
if verts and faces:
|
|
t_verts = transform_points(verts)
|
|
x, y, z = t_verts[:, 0], t_verts[:, 1], t_verts[:, 2]
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="gray",
|
|
opacity=opacity_hull,
|
|
name=f"Hull {i+1}",
|
|
showscale=False,
|
|
flatshading=True,
|
|
)
|
|
)
|
|
hull_indices.append(len(fig.data) - 1)
|
|
|
|
# 2. Tanks
|
|
if show_tanks:
|
|
for tank in vessel.get_tanks():
|
|
# Container
|
|
if hasattr(tank, "get_vertices"):
|
|
verts = tank.get_vertices()
|
|
faces = tank.get_faces()
|
|
if verts:
|
|
t_verts = transform_points(verts)
|
|
x, y, z = t_verts[:, 0], t_verts[:, 1], t_verts[:, 2]
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="lightgray",
|
|
opacity=opacity_tank,
|
|
name=f"{tank.name} (Tank)",
|
|
showscale=False,
|
|
)
|
|
)
|
|
|
|
# Fluid (Use heel/trim-aware getter)
|
|
if hasattr(tank, "get_fluid_vertices"):
|
|
# Pass vessel heel/trim to get the fluid mesh that is
|
|
# 'horizontal' in world but 'tilted' in ship frame.
|
|
verts = tank.get_fluid_vertices(heel=heel, trim=trim)
|
|
faces = tank.get_fluid_faces(heel=heel, trim=trim)
|
|
if verts:
|
|
# Apply SHIP transformation to place it in world
|
|
t_verts = transform_points(verts)
|
|
x, y, z = t_verts[:, 0], t_verts[:, 1], t_verts[:, 2]
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="blue",
|
|
opacity=0.8,
|
|
name=f"{tank.name} (Fluid)",
|
|
showscale=False,
|
|
)
|
|
)
|
|
|
|
# 3. Silhouettes (Lines)
|
|
if show_silhouettes:
|
|
for sil in vessel.get_silhouettes():
|
|
points = sil.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
# Close the loop if needed (in ship frame)
|
|
pts_nx3 = np.array(points)
|
|
if sil.is_closed():
|
|
# Check distance between first and last point
|
|
if np.linalg.norm(pts_nx3[0] - pts_nx3[-1]) > 1e-6:
|
|
pts_nx3 = np.vstack([pts_nx3, pts_nx3[0]])
|
|
|
|
# Transform to world
|
|
t_points = transform_points(pts_nx3)
|
|
x, y, z = t_points[:, 0], t_points[:, 1], t_points[:, 2]
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
mode="lines",
|
|
line=dict(color="green", width=2),
|
|
name=f"Silhouette: {sil.name}",
|
|
)
|
|
)
|
|
|
|
# 4. Openings (Markers)
|
|
if show_openings:
|
|
for opening in vessel.get_openings():
|
|
points = opening.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
pts_nx3 = np.array(points)
|
|
|
|
# If contour, draw lines
|
|
if len(points) > 1:
|
|
# Close loop
|
|
if np.linalg.norm(pts_nx3[0] - pts_nx3[-1]) > 1e-6:
|
|
pts_nx3 = np.vstack([pts_nx3, pts_nx3[0]])
|
|
|
|
t_points = transform_points(pts_nx3)
|
|
x, y, z = t_points[:, 0], t_points[:, 1], t_points[:, 2]
|
|
|
|
mode = "lines+markers"
|
|
else:
|
|
t_points = transform_points(pts_nx3)
|
|
x, y, z = t_points[:, 0], t_points[:, 1], t_points[:, 2]
|
|
mode = "markers"
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
mode=mode,
|
|
marker=dict(size=4, color="red"),
|
|
line=dict(color="red", width=2),
|
|
name=f"Opening: {opening.name}",
|
|
)
|
|
)
|
|
|
|
# 5. Appendages
|
|
if show_appendages:
|
|
for app in vessel.get_appendages():
|
|
geo_type = app.geometry_type()
|
|
|
|
if geo_type == "Mesh":
|
|
mesh_data = app.get_mesh_data()
|
|
if mesh_data:
|
|
verts, faces = mesh_data
|
|
if verts and faces:
|
|
t_verts = transform_points(verts)
|
|
x, y, z = t_verts[:, 0], t_verts[:, 1], t_verts[:, 2]
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x, y=y, z=z, i=i_idx, j=j_idx, k=k_idx,
|
|
color="darkkhaki",
|
|
opacity=opacity_appendage,
|
|
name=f"{app.name} (Appendage)",
|
|
showscale=False,
|
|
flatshading=True,
|
|
)
|
|
)
|
|
elif geo_type == "Box" or geo_type == "Cube":
|
|
bounds = app.bounds
|
|
if bounds:
|
|
xmin, xmax, ymin, ymax, zmin, zmax = bounds
|
|
# Create a box mesh
|
|
box_verts = [
|
|
(xmin, ymin, zmin), (xmax, ymin, zmin),
|
|
(xmax, ymax, zmin), (xmin, ymax, zmin),
|
|
(xmin, ymin, zmax), (xmax, ymin, zmax),
|
|
(xmax, ymax, zmax), (xmin, ymax, zmax)
|
|
]
|
|
t_verts = transform_points(box_verts)
|
|
x, y, z = t_verts[:, 0], t_verts[:, 1], t_verts[:, 2]
|
|
|
|
i = [7, 0, 0, 0, 4, 4, 6, 6, 4, 0, 3, 2]
|
|
j = [3, 4, 1, 2, 5, 6, 5, 2, 0, 1, 6, 3]
|
|
k = [0, 7, 2, 3, 6, 7, 1, 1, 5, 5, 7, 6]
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x, y=y, z=z, i=i, j=j, k=k,
|
|
color="goldenrod",
|
|
opacity=opacity_appendage,
|
|
name=f"{app.name} (Appendage)",
|
|
showscale=False,
|
|
)
|
|
)
|
|
elif geo_type == "Sphere" or geo_type == "Point":
|
|
center = app.center
|
|
volume = app.volume
|
|
t_center = transform_points([center])[0]
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[t_center[0]],
|
|
y=[t_center[1]],
|
|
z=[t_center[2]],
|
|
mode="markers",
|
|
marker=dict(
|
|
size=10, color="goldenrod", symbol="circle"),
|
|
name=f"{app.name} (Appendage)",
|
|
text=f"Vol: {volume:.3f}m³",
|
|
)
|
|
)
|
|
|
|
# 6. Deck Edges
|
|
if show_deck_edges:
|
|
for edge in vessel.get_deck_edges():
|
|
points = edge.get_points()
|
|
if not points:
|
|
continue
|
|
|
|
t_points = transform_points(points)
|
|
x, y, z = t_points[:, 0], t_points[:, 1], t_points[:, 2]
|
|
side = edge.get_side()
|
|
|
|
color = "orange"
|
|
if "Port" in side:
|
|
color = "red"
|
|
elif "Starboard" in side:
|
|
color = "green"
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=x, y=y, z=z,
|
|
mode="lines+markers",
|
|
line=dict(color=color, width=4),
|
|
marker=dict(size=3, color=color),
|
|
name=f"Deck Edge: {edge.name} ({side})",
|
|
)
|
|
)
|
|
|
|
# 7. Reference Points
|
|
ap_pt = transform_points([(vessel.ap, 0, 0)])[0]
|
|
fp_pt = transform_points([(vessel.fp, 0, 0)])[0]
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[ap_pt[0], fp_pt[0]],
|
|
y=[ap_pt[1], fp_pt[1]],
|
|
z=[ap_pt[2], fp_pt[2]],
|
|
mode="markers+text",
|
|
marker=dict(size=5, color="black", symbol="diamond"),
|
|
text=["AP", "FP"],
|
|
name="Perpendiculars",
|
|
)
|
|
)
|
|
|
|
# 8. Center of Gravity
|
|
if cog:
|
|
cog_pt = transform_points([cog])[0]
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[cog_pt[0]],
|
|
y=[cog_pt[1]],
|
|
z=[cog_pt[2]],
|
|
mode="markers+text",
|
|
marker=dict(size=8, color="orange", symbol="diamond"),
|
|
text=["COG"],
|
|
textposition="top center",
|
|
name="Center of Gravity",
|
|
)
|
|
)
|
|
|
|
# Opacity Slider
|
|
sliders = []
|
|
if enable_opacity_slider and hull_indices:
|
|
steps = []
|
|
for op in np.arange(0, 1.1, 0.1):
|
|
step = dict(
|
|
method="restyle",
|
|
args=[{"opacity": op}, hull_indices],
|
|
label=f"{op:.1f}",
|
|
)
|
|
steps.append(step)
|
|
|
|
sliders.append(
|
|
dict(
|
|
active=int(opacity_hull * 10),
|
|
currentvalue={"prefix": "Hull Opacity: "},
|
|
pad={"t": 50},
|
|
steps=steps,
|
|
)
|
|
)
|
|
|
|
scene_args: dict[str, Any] = dict(aspectmode="data")
|
|
|
|
if show_axes:
|
|
scene_args.update(
|
|
dict(
|
|
xaxis_title="X",
|
|
yaxis_title="Y",
|
|
zaxis_title="Z",
|
|
)
|
|
)
|
|
else:
|
|
scene_args.update(
|
|
dict(
|
|
xaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
yaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
zaxis=dict(
|
|
visible=False, showgrid=False, showbackground=False
|
|
),
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
title=title,
|
|
scene=scene_args,
|
|
sliders=sliders,
|
|
updatemenus=[
|
|
# 1. Projection (Left)
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
buttons=[
|
|
dict(
|
|
label="Persp 👁️",
|
|
method="relayout",
|
|
args=["scene.camera.projection.type", "perspective"],
|
|
),
|
|
dict(
|
|
label="Ortho 📐",
|
|
method="relayout",
|
|
args=["scene.camera.projection.type", "orthographic"],
|
|
),
|
|
],
|
|
pad={"r": 5, "t": 5},
|
|
showactive=True,
|
|
x=0.0,
|
|
xanchor="left",
|
|
y=1.1,
|
|
yanchor="top",
|
|
bgcolor="rgba(255, 255, 255, 0.7)",
|
|
),
|
|
# 2. Views (Right, with gap)
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
buttons=_get_camera_buttons(),
|
|
pad={"r": 5, "t": 5},
|
|
showactive=True,
|
|
x=0.25, # Gap from projection buttons
|
|
xanchor="left",
|
|
y=1.1,
|
|
yanchor="top",
|
|
bgcolor="rgba(255, 255, 255, 0.7)",
|
|
),
|
|
],
|
|
)
|
|
return fig
|
|
|
|
|
|
def _get_camera_buttons() -> List[dict]:
|
|
"""Returns a list of camera view buttons for Plotly."""
|
|
return [
|
|
dict(
|
|
label="ISO",
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=1.25, y=1.25, z=1.25),
|
|
"scene.camera.up": dict(x=0, y=0, z=1),
|
|
"scene.camera.projection.type": "perspective",
|
|
}
|
|
],
|
|
),
|
|
dict(
|
|
label="STBD", # Starboard
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=0, y=-2.5, z=0),
|
|
"scene.camera.up": dict(x=0, y=0, z=1),
|
|
"scene.camera.projection.type": "orthographic",
|
|
}
|
|
],
|
|
),
|
|
dict(
|
|
label="PORT", # Port
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=0, y=2.5, z=0),
|
|
"scene.camera.up": dict(x=0, y=0, z=1),
|
|
"scene.camera.projection.type": "orthographic",
|
|
}
|
|
],
|
|
),
|
|
dict(
|
|
label="TOP",
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=0, y=0, z=2.5),
|
|
"scene.camera.up": dict(x=1, y=0, z=0),
|
|
"scene.camera.projection.type": "orthographic",
|
|
}
|
|
],
|
|
),
|
|
dict(
|
|
label="BOW",
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=2.5, y=0, z=0),
|
|
"scene.camera.up": dict(x=0, y=0, z=1),
|
|
"scene.camera.projection.type": "orthographic",
|
|
}
|
|
],
|
|
),
|
|
dict(
|
|
label="AFT", # Stern
|
|
method="relayout",
|
|
args=[
|
|
{
|
|
"scene.camera.eye": dict(x=-2.5, y=0, z=0),
|
|
"scene.camera.up": dict(x=0, y=0, z=1),
|
|
"scene.camera.projection.type": "orthographic",
|
|
}
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
def _add_hull_trace(fig: go.Figure, hull: Hull, name: str, opacity: float):
|
|
"""Helper to add hull mesh to figure."""
|
|
if hasattr(hull, "get_vertices") and hasattr(hull, "get_faces"):
|
|
verts = hull.get_vertices()
|
|
faces = hull.get_faces()
|
|
if verts and faces:
|
|
x, y, z = zip(*verts)
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="gray",
|
|
opacity=opacity,
|
|
name=name,
|
|
showscale=False,
|
|
flatshading=True,
|
|
)
|
|
)
|
|
|
|
|
|
def _add_appendage_trace(fig: go.Figure, app: Any, opacity: float):
|
|
"""Helper to add appendage to figure."""
|
|
geo_type = app.geometry_type()
|
|
|
|
if geo_type == "Mesh":
|
|
mesh_data = app.get_mesh_data()
|
|
if mesh_data:
|
|
verts, faces = mesh_data
|
|
if verts and faces:
|
|
x, y, z = zip(*verts)
|
|
i_idx, j_idx, k_idx = zip(*faces)
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x,
|
|
y=y,
|
|
z=z,
|
|
i=i_idx,
|
|
j=j_idx,
|
|
k=k_idx,
|
|
color="darkkhaki",
|
|
opacity=opacity,
|
|
name=f"{app.name} (Appendage)",
|
|
showscale=False,
|
|
flatshading=True,
|
|
)
|
|
)
|
|
elif geo_type == "Box" or geo_type == "Cube":
|
|
bounds = app.bounds
|
|
if bounds:
|
|
xmin, xmax, ymin, ymax, zmin, zmax = bounds
|
|
|
|
# Create a box mesh
|
|
x = [xmin, xmin, xmax, xmax, xmin, xmin, xmax, xmax]
|
|
y = [ymin, ymax, ymax, ymin, ymin, ymax, ymax, ymin]
|
|
z = [zmin, zmin, zmin, zmin, zmax, zmax, zmax, zmax]
|
|
|
|
i = [7, 0, 0, 0, 4, 4, 6, 6, 4, 0, 3, 2]
|
|
j = [3, 4, 1, 2, 5, 6, 5, 2, 0, 1, 6, 3]
|
|
k = [0, 7, 2, 3, 6, 7, 1, 1, 5, 5, 7, 6]
|
|
|
|
fig.add_trace(
|
|
go.Mesh3d(
|
|
x=x, y=y, z=z, i=i, j=j, k=k,
|
|
color="goldenrod",
|
|
opacity=opacity,
|
|
name=f"{app.name} (Appendage)",
|
|
showscale=False,
|
|
)
|
|
)
|
|
elif geo_type == "Sphere" or geo_type == "Point":
|
|
# Draw as a marker or simple sphere if we want
|
|
center = app.center
|
|
volume = app.volume
|
|
|
|
# Simple marker for now
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[center[0]],
|
|
y=[center[1]],
|
|
z=[center[2]],
|
|
mode="markers",
|
|
marker=dict(size=10, color="goldenrod", symbol="circle"),
|
|
name=f"{app.name} (Appendage)",
|
|
text=f"Vol: {volume:.3f}m³",
|
|
)
|
|
)
|