curvedarch / app.py
jpata's picture
labeling
5a8852a
Raw
History Blame Contribute Delete
23 kB
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()