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