Files

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³",
)
)