import streamlit as st import plotly.graph_objects as go import numpy as np import os import math from datetime import datetime from code.vault_logic import get_alternating_catenaries, generate_vault_meshes, compute_max_safe_cut_radius, generate_envelope_catenaries, generate_support_beams, generate_perimeter_beams, generate_beam_patterns from code.crossvault import run_tna_simulation from code.vault_shared import CONFIG from code.vault_plots import create_structural_plot from code.packing import pack_strips, pack_strips_multi from code.export import export_plywood_layout_pdf def mesh_to_plotly_dict(mesh, color='lightblue', opacity=0.8, name='Mesh'): # Extract vertices and faces for Plotly Mesh3d vertices = np.array([mesh.vertex_coordinates(v) for v in mesh.vertices()]) faces = [mesh.face_vertices(f) for f in mesh.faces()] # Triangulate quads tri_i, tri_j, tri_k = [], [], [] for face in faces: if len(face) == 3: tri_i.append(face[0]); tri_j.append(face[1]); tri_k.append(face[2]) elif len(face) == 4: tri_i.append(face[0]); tri_j.append(face[1]); tri_k.append(face[2]) tri_i.append(face[0]); tri_j.append(face[2]); tri_k.append(face[3]) return dict( type='mesh3d', x=vertices[:, 0], y=vertices[:, 1], z=vertices[:, 2], i=tri_i, j=tri_j, k=tri_k, color=color, opacity=opacity, name=name, showlegend=True ) def mesh_to_plotly_with_distortion(mesh, distortions, name='Flat Strip', cmin=0.0, cmax=0.01, showscale=False): vertices = np.array([mesh.vertex_coordinates(v) for v in mesh.vertices()]) faces = [mesh.face_vertices(f) for f in mesh.faces()] v_intensity = np.zeros(len(vertices)) v_count = np.zeros(len(vertices)) for f_idx, face in enumerate(faces): for v_idx in face: v_intensity[v_idx] += distortions[f_idx] v_count[v_idx] += 1 v_intensity = v_intensity / np.maximum(v_count, 1) tri_i, tri_j, tri_k = [], [], [] for face in faces: if len(face) == 3: tri_i.append(face[0]); tri_j.append(face[1]); tri_k.append(face[2]) elif len(face) == 4: tri_i.append(face[0]); tri_j.append(face[1]); tri_k.append(face[2]) tri_i.append(face[0]); tri_j.append(face[2]); tri_k.append(face[3]) return dict( type='mesh3d', x=vertices[:, 0], y=vertices[:, 1], z=vertices[:, 2], i=tri_i, j=tri_j, k=tri_k, intensity=v_intensity, colorscale='Viridis', cmin=cmin, cmax=cmax, showscale=showscale, colorbar=dict(title='Distortion', x=1.1) if showscale else None, opacity=0.9, name=name, showlegend=True ) def main(): st.set_page_config(page_title="Corrugated Vault Design", layout="wide") st.title("Corrugated Vault: Parametric Design & TNA Solver") st.sidebar.header("1. Vault Geometry") # v_type = st.sidebar.selectbox("Vault Type", ["fan", "cross"], index=0 if CONFIG['vault_type'] == 'fan' else 1) v_type = "fan" st.sidebar.info("Vault Type: Fan (Cross vault is currently disabled)") span_x = st.sidebar.slider("Span X", 1.0, 20.0, 3.0, step=0.1, help="Total dimension of the vault along the X-axis (m).") span_y = st.sidebar.slider("Span Y", 1.0, 20.0, 1.8, step=0.1, help="Total dimension of the vault along the Y-axis (m).") rise = st.sidebar.slider("Max Rise", 0.1, 5.0, 0.4, help="The maximum vertical height of the middle surface at the crown.") thick = st.sidebar.slider("Thickness", 0.01, 1.0, 0.1, help="The structural thickness of the vault, defining the distance between intrados and extrados.") ply_thick_mm = st.sidebar.number_input("Plywood Thickness (mm)", 1.0, 50.0, 12.0, step=0.1, help="Material thickness of the plywood strips. The support beam will be offset by this amount.") ply_thick = ply_thick_mm / 1000.0 st.sidebar.header("2. TNA Parameters") if v_type == "fan": discr_x = st.sidebar.slider("Ribs X", 2, 30, 8, help="Number of radial ribs along the X boundary for TNA discretization.") discr_y = st.sidebar.slider("Ribs Y", 2, 30, 6, help="Number of radial ribs along the Y boundary for TNA discretization.") discr = st.sidebar.number_input("Hoop Discretisation", 5, 40, 6, help="Number of concentric 'hoop' segments from support to crown.") else: discr = st.sidebar.number_input("Form Discretisation", 5, 40, 12, help="Grid resolution for the cross-vault TNA diagram.") discr_x, discr_y = discr, discr solver = st.sidebar.selectbox("Solver", ["IPOPT", "SLSQP"], index=0, help="Numerical optimization engine used to solve for horizontal thrust.") st.sidebar.header("3. Unrolling Parameters") flat_z = -1 n_catenaries = st.sidebar.slider("Number of Catenaries", 2, 60, 17, help="Total number of corrugation spokes. Increasing this makes the corrugations denser.") corner_cut = st.sidebar.slider( "Corner Cut Radius", 0.0, 0.2, 0.1, help="Trims the tight convergence of spokes at the supports. Essential for avoiding geometric singularities." ) st.sidebar.header("4. Visibility") show_3d = st.sidebar.checkbox("Show 3D Surface", value=True, help="Toggle visibility of the corrugated 3D strips.") show_beams = st.sidebar.checkbox("Show Support Beams", value=True, help="Toggle visibility of the central cross-shaped support beams.") show_perimeter_beams = st.sidebar.checkbox("Show Perimeter Beams", value=True, help="Toggle visibility of the 4 outer perimeter support beams.") show_flat = st.sidebar.checkbox("Show Flat Patterns", value=True, help="Toggle visibility of the 2D unrolled manufacturing patterns.") show_cats = st.sidebar.checkbox("Show Catenary Lines", value=True, help="Visualize the skeleton polylines used to build the corrugated surface.") show_pts = st.sidebar.checkbox("Show Vertex Points", value=True, help="Visualize the discrete vertices along the catenaries.") show_intrados = st.sidebar.checkbox("Show Intrados (Envelope)", value=True, help="Visualize the lower boundary surface of the vault.") show_extrados = st.sidebar.checkbox("Show Extrados (Envelope)", value=True, help="Visualize the upper boundary surface of the vault.") st.sidebar.header("5. Plywood Layout") packing_mode = st.sidebar.radio("Packing Mode", ["Single Custom Sheet", "Multiple Standard Sheets"], help="Single: finds the minimal sheet to fit everything. Multiple: uses fixed sheet size and multiple pages.") sheet_w = st.sidebar.number_input("Sheet Width (m)", 0.05, 10.0, 2.44, step=0.001, help="Width of the sheet (Default: 3.0m)") sheet_h = st.sidebar.number_input("Sheet Height (m)", 0.05, 10.0, 1.22, step=0.001, help="Height of the sheet (Default: 2.1m)") sheet_margin = st.sidebar.number_input("Packing Margin (m)", 0.0, 0.5, 0.1, step=0.01, help="Minimum distance between strips and sheet edges.") optimize_rot = st.sidebar.checkbox("Optimize Orientation", value=False, help="Rotate strips to minimize their bounding box height before packing.") # Session state to store simulation results if 'sim_data' not in st.session_state: st.session_state.sim_data = None if 'center_coords' not in st.session_state: st.session_state.center_coords = (5.0, 5.0) if 'current_config' not in st.session_state: st.session_state.current_config = CONFIG.copy() if st.sidebar.button("Execute TNA Analysis"): center_x = span_x / 2.0 center_y = span_y / 2.0 st.session_state.center_coords = (center_x, center_y) st.session_state.current_config = { 'xy_span': [[0.0, span_x], [0.0, span_y]], 'thickness': thick, 'max_rise': rise, 'discretisation_level': 40, 'form_discretisation': discr, 'form_discretisation_x': discr_x, 'form_discretisation_y': discr_y, 'solver': solver, 'support_type': 'corners', 'vault_type': v_type } with st.spinner("Solving Thrust Networks..."): try: data = run_tna_simulation(st.session_state.current_config) st.session_state.sim_data = data st.success("TNA Solver converged!") except Exception as e: st.error(f"Solver failed: {e}") # Construct reactive config for geometry generation active_config = { 'xy_span': [[0.0, span_x], [0.0, span_y]], 'thickness': thick, 'max_rise': rise, 'vault_type': v_type } # --- GEOMETRY GENERATION (Available to all tabs) --- beam_meshes = [] perimeter_beam_meshes = [] try: with st.spinner("Generating Corrugated Geometry..."): quadrant_catenaries = generate_envelope_catenaries( active_config, n_spokes=n_catenaries, n_points=discr + 1, corner_cut_radius=corner_cut ) beam_meshes = generate_support_beams(active_config, n_spokes=n_catenaries, ply_thickness=ply_thick) perimeter_beam_meshes = generate_perimeter_beams(active_config, n_spokes=n_catenaries, ply_thickness=ply_thick) beam_patterns = generate_beam_patterns(active_config, n_spokes=n_catenaries, ply_thickness=ply_thick) meshes_3d, meshes_flat, distortions = [], [], [] all_cats_flat = [] for quad_cats in quadrant_catenaries: m3d, mflat, dists = generate_vault_meshes(quad_cats, flat_z) meshes_3d.extend(m3d) meshes_flat.extend(mflat) distortions.extend(dists) all_cats_flat.extend(quad_cats) except ValueError as ve: st.error(f"Geometry Generation Error: {ve}") st.info("Try reducing the **Corner Cut Radius** to ensure all spokes retain the same number of nodes.") return # Visualization tab1, tab2, tab3 = st.tabs(["Corrugated Geometry", "Structural Validation", "Plywood Layout"]) with tab1: fig = go.Figure() # Add Catenaries if show_cats: for i, cat in enumerate(all_cats_flat): pts = np.array(cat.points) fig.add_trace(go.Scatter3d( x=pts[:, 0], y=pts[:, 1], z=pts[:, 2], mode='lines', line=dict(color='black', width=3), name=f"Catenary {i}", showlegend=False )) # Add Points if show_pts: for i, cat in enumerate(all_cats_flat): pts = np.array(cat.points) fig.add_trace(go.Scatter3d( x=pts[:, 0], y=pts[:, 1], z=pts[:, 2], mode='markers', marker=dict(size=3, color='red'), name=f"Points {i}", showlegend=False )) # Add 3D Surfaces if show_3d: for i, m in enumerate(meshes_3d): color = 'rgb(100, 150, 240)' if i % 4 < 2 else 'rgb(240, 150, 100)' fig.add_trace(go.Mesh3d(**mesh_to_plotly_dict(m, color=color, name=f"Strip {i}"))) # Add Support Beams if show_beams: for i, m in enumerate(beam_meshes): fig.add_trace(go.Mesh3d(**mesh_to_plotly_dict(m, color='rgb(100, 100, 100)', opacity=1.0, name=f"Beam {i}"))) # Add Perimeter Beams if show_perimeter_beams: for i, m in enumerate(perimeter_beam_meshes): fig.add_trace(go.Mesh3d(**mesh_to_plotly_dict(m, color='rgb(80, 80, 80)', opacity=1.0, name=f"Perimeter Beam {i}"))) # Add Flat Patterns if show_flat: all_distortions = [d for sublist in distortions for d in sublist] if distortions else [] global_max_d = max(all_distortions) if all_distortions else 0.01 if global_max_d < 1e-6: global_max_d = 0.01 colorbar_max = math.ceil(global_max_d * 100) / 100.0 if colorbar_max == 0: colorbar_max = 0.01 # Only show strips for the first quadrant (n_catenaries - 1 strips) strips_per_quad = n_catenaries - 1 for i, (m, d) in enumerate(zip(meshes_flat[:strips_per_quad], distortions[:strips_per_quad])): if m: show_colorbar = (i == 0) fig.add_trace(go.Mesh3d(**mesh_to_plotly_with_distortion( m, d, name=f"Flat {i}", cmin=0.0, cmax=colorbar_max, showscale=show_colorbar ))) # Add Envelope Surfaces (Always compute them reactively) from compas.datastructures import Mesh from code.vault_shared import fanvault_middle_hc, crossvault_middle_hc # Simple reactive envelope generation def get_envelope_mesh(config, side='middle'): x_span = config['xy_span'][0] y_span = config['xy_span'][1] hc = config['max_rise'] t = config['thickness'] n = 30 # Resolution for envelope from compas_tna.diagrams.diagram_rectangular import create_cross_mesh m = create_cross_mesh(x_span=x_span, y_span=y_span, n=n) for v in m.vertices(): x, y = m.vertex_attributes(v, names=["x", "y"]) if config['vault_type'] == 'fan': z = fanvault_middle_hc([x], [y], x_span, y_span, hc)[0] else: z = crossvault_middle_hc([x], [y], x_span, y_span, hc)[0] if side == 'intrados': z -= t/2 elif side == 'extrados': z += t/2 m.vertex_attribute(v, "z", z) return m if show_intrados: i_mesh = get_envelope_mesh(active_config, side='intrados') fig.add_trace(go.Mesh3d(**mesh_to_plotly_dict(i_mesh, color='cyan', opacity=0.25, name='Intrados'))) if show_extrados: e_mesh = get_envelope_mesh(active_config, side='extrados') fig.add_trace(go.Mesh3d(**mesh_to_plotly_dict(e_mesh, color='tan', opacity=0.25, name='Extrados'))) fig.update_layout( scene=dict(aspectmode='data', xaxis_title='X', yaxis_title='Y', zaxis_title='Z'), margin=dict(l=0, r=0, b=0, t=40), height=800 ) st.plotly_chart(fig, use_container_width=True) st.subheader("Design Statistics") col1, col2, col3 = st.columns(3) col1.metric("Total Strips", len(meshes_3d)) all_d = [item for sublist in distortions for item in sublist] if all_d: col2.metric("Max Distortion", f"{max(all_d):.4f}") col3.metric("Avg Distortion", f"{np.mean(all_d):.4f}") with tab2: if st.session_state.sim_data: st.subheader("Intrados, Extrados & Thrust Networks") view_col1, view_col2 = st.columns(2) with view_col1: st.write("**Isometric View**") fig_iso = create_structural_plot(st.session_state.sim_data, st.session_state.current_config, title="Isometric") st.pyplot(fig_iso) st.write("**Top View**") fig_top = create_structural_plot(st.session_state.sim_data, st.session_state.current_config, elevation=90, azimuth=-90, title="Top") st.pyplot(fig_top) with view_col2: st.write("**Front View**") fig_front = create_structural_plot(st.session_state.sim_data, st.session_state.current_config, elevation=0, azimuth=-90, title="Front") st.pyplot(fig_front) st.write("**Right View**") fig_right = create_structural_plot(st.session_state.sim_data, st.session_state.current_config, elevation=0, azimuth=0, title="Right") st.pyplot(fig_right) else: st.info("Click 'Execute TNA Analysis' to generate structural validation data.") with tab3: st.subheader("Plywood Packing Layout (Quadrant 1 Only)") # Calculate strips per quadrant strips_per_quad = n_catenaries - 1 valid_flat_meshes = [m for m in meshes_flat[:strips_per_quad] if m] valid_flat_meshes.extend(beam_patterns) if not valid_flat_meshes: st.warning("No flat patterns available to pack. Adjust parameters or check generation errors.") else: if packing_mode == "Single Custom Sheet": with st.spinner("Packing Strips (Single Sheet)..."): packed_meshes, success, used_dims = pack_strips( valid_flat_meshes, sheet_w, 100.0, # Huge height for custom mode margin=sheet_margin, optimize_rotation=optimize_rot ) # Format for display and export sheets_to_show = [{ 'meshes': packed_meshes, 'w': used_dims[0] + 0.05, 'h': used_dims[1] + 0.05, 'success': True }] st.success(f"All {len(packed_meshes)} strips packed! Suggested sheet size: {used_dims[0] + 0.05:.2f}m x {used_dims[1] + 0.05:.2f}m") else: with st.spinner("Packing Strips (Multiple Sheets)..."): packed_sheets = pack_strips_multi( valid_flat_meshes, sheet_w, sheet_h, margin=sheet_margin, optimize_rotation=optimize_rot ) num_packed = sum(len(s['meshes']) for s in packed_sheets) st.success(f"Packed {num_packed} / {len(valid_flat_meshes)} strips onto {len(packed_sheets)} sheets.") sheets_to_show = [] for i, s in enumerate(packed_sheets): sheets_to_show.append({ 'meshes': s['meshes'], 'w': sheet_w, 'h': sheet_h, 'title': f"Sheet {i+1}", 'success': True }) # Visualization for i, sheet in enumerate(sheets_to_show): if len(sheets_to_show) > 1: st.write(f"### {sheet.get('title', f'Sheet {i+1}')}") fig_pack = go.Figure() final_w, final_h = sheet['w'], sheet['h'] fig_pack.add_shape( type="rect", x0=0, y0=0, x1=final_w, y1=final_h, line=dict(color="RoyalBlue", width=3), fillcolor="BurlyWood", opacity=0.1, name="Plywood Sheet" ) # Draw Packed Meshes and Labels x_coords, y_coords = [], [] text_x, text_y, text_labels = [], [], [] from code.packing import get_mesh_2d_bbox for m in sheet['meshes']: # Outline for edge in m.edges(): p1, p2 = m.edge_coordinates(edge) x_coords.extend([p1[0], p2[0], None]) y_coords.extend([p1[1], p2[1], None]) # Label at center of bbox name = m.attributes.get('name', '???') min_x, min_y, max_x, max_y = get_mesh_2d_bbox(m) text_x.append((min_x + max_x) / 2) text_y.append((min_y + max_y) / 2) text_labels.append(name) fig_pack.add_trace(go.Scatter( x=x_coords, y=y_coords, mode='lines', line=dict(color='black', width=1), showlegend=False, name="Packed Strips", hoverinfo='none' )) fig_pack.add_trace(go.Scatter( x=text_x, y=text_y, mode='text', text=text_labels, textposition='middle center', textfont=dict(size=10, color='blue'), showlegend=False, name="Labels" )) fig_pack.update_layout( xaxis=dict(title="Width (m)", range=[-0.1, final_w + 0.1]), yaxis=dict(title="Height (m)", range=[-0.1, final_h + 0.1], scaleanchor="x", scaleratio=1), width=1000, height=700, margin=dict(l=20, r=20, t=40, b=20), template="plotly_white" ) st.plotly_chart(fig_pack, use_container_width=True, key=f"pack_fig_{i}") st.subheader("Layout Metrics") m1, m2, m3 = st.columns(3) total_packed = sum(len(s['meshes']) for s in sheets_to_show) m1.metric("Strips Placed", f"{total_packed} / {len(valid_flat_meshes)}") m2.metric("Total Sheets", len(sheets_to_show)) # Utilization total_used_area = 0 total_sheet_area = sum(s['w'] * s['h'] for s in sheets_to_show) from code.packing import get_mesh_2d_bbox for s in sheets_to_show: for m in s['meshes']: min_x, min_y, max_x, max_y = get_mesh_2d_bbox(m) total_used_area += (max_x - min_x) * (max_y - min_y) if total_sheet_area > 0: utilization = (total_used_area / total_sheet_area) * 100 m3.metric("Avg BBox Utilization", f"{utilization:.1f}%") st.write("---") st.subheader("Manufacturing Export") # Prepare data for export export_data = [{'meshes': s['meshes'], 'w': s['w'], 'h': s['h']} for s in sheets_to_show] pdf_buf = export_plywood_layout_pdf(export_data, color='red') st.download_button( label=f"📥 Download Cut Patterns ({len(sheets_to_show)} Page PDF)", data=pdf_buf, file_name=f"vault_cut_patterns_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", mime="application/pdf", help="Download a high-fidelity vector PDF for CNC or laser cutting. Preserves exact dimensions." ) if __name__ == "__main__": main()