| """ |
| VCA Body Shell 3D Visualization |
| ================================ |
| Renders the VCA body shell STL file provided by David Clark, with |
| magnetic induction landing plate per his specifications. |
| |
| Animation: vertical Z-axis movement based on buoyancy state. |
| |
| Landing system per David Clark's specifications: |
| - Magnetic induction landing plate (no physical contact) |
| - Design gap: 0.40m between bottom of shell and top of plate |
| - Plate diameter: 130% of body shell diameter |
| - ConOps visualization: hover, ascend, descend |
| |
| The STL is pre-processed into a JSON mesh file (data/vca_shell_mesh.json) |
| to avoid parsing binary STL at runtime. |
| """ |
|
|
| import json |
| import os |
| import numpy as np |
| import plotly.graph_objects as go |
| from visualization.gauges import ( |
| C_TEXT, C_TEXT_DIM, C_GREEN, C_AMBER, C_RED, C_CYAN, FONT_FAMILY, TRANSPARENT, |
| ) |
|
|
| |
| |
| |
| SHELL_DIAMETER = 350.65 |
| SHELL_HEIGHT = 90.12 |
| SHELL_BOTTOM_Z = -10.0 |
| SHELL_TOP_Z = 80.12 |
|
|
| |
| PLATE_DIAMETER = SHELL_DIAMETER * 1.30 |
| PLATE_THICKNESS = 3.0 |
| DESIGN_GAP = 0.40 * 350.65 / 9.0 |
| PLATE_Z = SHELL_BOTTOM_Z - DESIGN_GAP |
|
|
| |
| Z_OFFSET_POSITIVE = 60.0 |
| Z_OFFSET_NEUTRAL = 0.0 |
| Z_OFFSET_NEGATIVE = -15.0 |
|
|
|
|
| |
| |
| |
|
|
| _mesh_cache = {} |
|
|
| def load_shell_mesh(quality="full"): |
| """ |
| Load the pre-processed VCA shell mesh from JSON. |
| quality: "full" (25K verts, best visuals) or "light" (16K verts, faster) |
| """ |
| if quality in _mesh_cache: |
| return _mesh_cache[quality] |
|
|
| if quality == "light": |
| filenames = ["vca_shell_mesh_light.json", "vca_shell_mesh.json"] |
| else: |
| filenames = ["vca_shell_mesh.json", "vca_shell_mesh_light.json"] |
|
|
| paths = [] |
| for fn in filenames: |
| paths.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", fn)) |
| paths.append(os.path.join("data", fn)) |
| paths.append(fn) |
|
|
| for path in paths: |
| if os.path.exists(path): |
| with open(path, "r") as f: |
| _mesh_cache[quality] = json.load(f) |
| return _mesh_cache[quality] |
|
|
| return None |
|
|
|
|
| |
| |
| |
|
|
| def create_landing_plate(z_position, diameter, resolution=60): |
| """Create a flat circular disk (landing plate) as Mesh3d data.""" |
| radius = diameter / 2.0 |
| angles = np.linspace(0, 2 * np.pi, resolution, endpoint=False) |
|
|
| cx, cy = 0.0, 0.0 |
| x_vals = [cx] + [cx + radius * np.cos(a) for a in angles] |
| y_vals = [cy] + [cy + radius * np.sin(a) for a in angles] |
| z_vals = [z_position] * (resolution + 1) |
|
|
| i_vals, j_vals, k_vals = [], [], [] |
| for idx in range(resolution): |
| i_vals.append(0) |
| j_vals.append(idx + 1) |
| k_vals.append((idx + 1) % resolution + 1) |
|
|
| return { |
| "x": x_vals, "y": y_vals, "z": z_vals, |
| "i": i_vals, "j": j_vals, "k": k_vals, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def create_magnetic_ring(z_position, inner_r, outer_r, resolution=60): |
| """Create a ring (annulus) representing the magnetic induction coil.""" |
| angles = np.linspace(0, 2 * np.pi, resolution, endpoint=False) |
|
|
| x_vals, y_vals, z_vals = [], [], [] |
| i_vals, j_vals, k_vals = [], [], [] |
|
|
| for a in angles: |
| x_vals.append(inner_r * np.cos(a)) |
| y_vals.append(inner_r * np.sin(a)) |
| z_vals.append(z_position) |
| for a in angles: |
| x_vals.append(outer_r * np.cos(a)) |
| y_vals.append(outer_r * np.sin(a)) |
| z_vals.append(z_position) |
|
|
| n = resolution |
| for idx in range(n): |
| next_idx = (idx + 1) % n |
| i_vals.extend([idx, idx, n + idx]) |
| j_vals.extend([next_idx, n + idx, n + next_idx]) |
| k_vals.extend([n + idx, n + next_idx, next_idx]) |
|
|
| return { |
| "x": x_vals, "y": y_vals, "z": z_vals, |
| "i": i_vals, "j": j_vals, "k": k_vals, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def build_3d_scene(buoyancy_state, net_force_N=0.0, quality="full"): |
| """ |
| Build the complete 3D ConOps visualization. |
| |
| Parameters |
| ---------- |
| buoyancy_state : str |
| "Positive Buoyancy", "Neutral Buoyancy", or "Negative Buoyancy" |
| net_force_N : float |
| Net vertical force for proportional animation offset. |
| |
| Returns |
| ------- |
| go.Figure |
| Plotly 3D figure with VCA shell STL, plate, and magnetic ring. |
| """ |
| mesh = load_shell_mesh(quality) |
|
|
| |
| if buoyancy_state == "Positive Buoyancy": |
| max_offset = Z_OFFSET_POSITIVE |
| z_offset = min(max_offset, max(10.0, abs(net_force_N) / 100.0)) |
| elif buoyancy_state == "Negative Buoyancy": |
| z_offset = Z_OFFSET_NEGATIVE |
| else: |
| z_offset = Z_OFFSET_NEUTRAL |
|
|
| |
| state_colors = { |
| "Positive Buoyancy": C_GREEN, |
| "Neutral Buoyancy": C_AMBER, |
| "Negative Buoyancy": C_RED, |
| } |
| shell_color = state_colors.get(buoyancy_state, C_TEXT_DIM) |
|
|
| fig = go.Figure() |
|
|
| |
| if mesh: |
| z_shifted = [z + z_offset for z in mesh["z"]] |
| fig.add_trace(go.Mesh3d( |
| x=mesh["x"], y=mesh["y"], z=z_shifted, |
| i=mesh["i"], j=mesh["j"], k=mesh["k"], |
| color=shell_color, |
| opacity=0.85, |
| flatshading=True, |
| lighting=dict( |
| ambient=0.4, diffuse=0.6, specular=0.3, |
| roughness=0.5, fresnel=0.2, |
| ), |
| lightposition=dict(x=200, y=200, z=300), |
| name="VCA Body Shell", |
| showlegend=True, |
| hoverinfo="name", |
| )) |
| else: |
| |
| u = np.linspace(0, 2*np.pi, 30) |
| v = np.linspace(0, np.pi, 20) |
| r = SHELL_DIAMETER / 2 |
| x = r * np.outer(np.cos(u), np.sin(v)).flatten() |
| y = r * np.outer(np.sin(u), np.sin(v)).flatten() |
| z = (r * np.outer(np.ones(np.size(u)), np.cos(v)).flatten() |
| + SHELL_TOP_Z/2 + z_offset) |
| fig.add_trace(go.Scatter3d( |
| x=x, y=y, z=z, mode="markers", |
| marker=dict(size=1, color=shell_color, opacity=0.5), |
| name="VCA Shell (simplified)", |
| )) |
|
|
| |
| plate = create_landing_plate(PLATE_Z, PLATE_DIAMETER) |
| fig.add_trace(go.Mesh3d( |
| x=plate["x"], y=plate["y"], z=plate["z"], |
| i=plate["i"], j=plate["j"], k=plate["k"], |
| color="#2A3A4E", |
| opacity=0.9, |
| flatshading=True, |
| name="Landing Plate", |
| showlegend=True, |
| hoverinfo="name", |
| visible="legendonly", |
| )) |
|
|
| |
| ring = create_magnetic_ring( |
| PLATE_Z + 0.5, |
| inner_r=SHELL_DIAMETER * 0.35, |
| outer_r=SHELL_DIAMETER * 0.45, |
| ) |
| ring_color = "#5BA4B5" if buoyancy_state != "Negative Buoyancy" else "#4A5568" |
| fig.add_trace(go.Mesh3d( |
| x=ring["x"], y=ring["y"], z=ring["z"], |
| i=ring["i"], j=ring["j"], k=ring["k"], |
| color=ring_color, |
| opacity=0.7, |
| flatshading=True, |
| name="Magnetic Coil", |
| showlegend=True, |
| hoverinfo="name", |
| visible="legendonly", |
| )) |
|
|
| |
| scene_range = PLATE_DIAMETER * 0.7 |
| z_min = PLATE_Z - 20 |
| z_max = SHELL_TOP_Z + Z_OFFSET_POSITIVE + 40 |
|
|
| fig.update_layout( |
| paper_bgcolor=TRANSPARENT, |
| plot_bgcolor=TRANSPARENT, |
| uirevision="constant", |
| font=dict(color=C_TEXT, family=FONT_FAMILY, size=10), |
| scene=dict( |
| xaxis=dict( |
| visible=False, |
| range=[-scene_range, scene_range], |
| showbackground=False, |
| ), |
| yaxis=dict( |
| visible=False, |
| range=[-scene_range, scene_range], |
| showbackground=False, |
| ), |
| zaxis=dict( |
| visible=True, |
| range=[z_min, z_max], |
| showgrid=True, |
| gridcolor="#1A2535", |
| showbackground=False, |
| title=dict(text="Altitude", font=dict(size=10, color=C_TEXT_DIM)), |
| tickfont=dict(size=8, color=C_TEXT_DIM), |
| ), |
| bgcolor="#0B0F14", |
| camera=dict( |
| eye=dict(x=1.5, y=1.5, z=0.8), |
| up=dict(x=0, y=0, z=1), |
| ), |
| aspectmode="data", |
| ), |
| margin=dict(l=0, r=0, t=30, b=0), |
| height=450, |
| showlegend=True, |
| legend=dict( |
| font=dict(size=10, color=C_TEXT_DIM), |
| bgcolor="rgba(0,0,0,0)", |
| x=0.01, y=0.99, |
| ), |
| annotations=[ |
| dict( |
| text=f"<b>{buoyancy_state.upper()}</b>", |
| x=0.5, y=0.97, xref="paper", yref="paper", |
| showarrow=False, |
| font=dict(size=14, color=shell_color, family=FONT_FAMILY), |
| ), |
| ], |
| ) |
|
|
| return fig |