#!/usr/bin/env python3 import os import math import numpy as np import matplotlib.pyplot as plt import matplotlib.colors as mcolors from datetime import datetime import logging import traceback from code.vault_logic import get_alternating_catenaries, generate_vault_meshes, compute_max_safe_cut_radius, generate_envelope_catenaries 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 # ----------------------------------------------------------------------------- # Configuration & Test Cases # ----------------------------------------------------------------------------- SHEET_W = 3.0 SHEET_H = 2.1 SHEET_MARGIN = 0.02 TEST_CASES = [ { "name": "iass_2024_prototype_3x1_8", "span_x": 3.0, "span_y": 1.8, "rise": 0.4, "thick": 0.10, "discr": 8, "corner_cut_ratio": 0.7 }, { "name": "asymmetric_discr_fan", "span_x": 3.0, "span_y": 1.8, "rise": 0.4, "thick": 0.10, "discr": 10, "discr_x": 8, "discr_y": 4, "corner_cut_ratio": 0.7 } ] # ----------------------------------------------------------------------------- # Validation Helpers # ----------------------------------------------------------------------------- def calculate_asymmetry(diagram, xm, ym): """ Calculate numerical asymmetry of the diagram around its center. Returns the maximum difference in mean Z-coordinates between symmetric halves. """ vertices = list(diagram.vertices()) coords = np.array([diagram.vertex_coordinates(v) for v in vertices]) x, y, z = coords[:, 0], coords[:, 1], coords[:, 2] # X-axis symmetry (Left vs Right) left = z[x < (xm - 0.01)] right = z[x > (xm + 0.01)] asym_x = abs(np.mean(left) - np.mean(right)) if len(left) > 0 and len(right) > 0 else 0 # Y-axis symmetry (Bottom vs Top) bottom = z[y < (ym - 0.01)] top = z[y > (ym + 0.01)] asym_y = abs(np.mean(bottom) - np.mean(top)) if len(bottom) > 0 and len(top) > 0 else 0 return max(asym_x, asym_y) # ----------------------------------------------------------------------------- # Plotting Helpers (Matplotlib) # ----------------------------------------------------------------------------- def plot_geometry_matplotlib(meshes_3d, catenaries, config, output_path, title="Corrugated Geometry", elevation=30, azimuth=-135): """Render the 3D corrugated geometry using Matplotlib.""" fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') # 1. Plot Catenaries for cat in catenaries: pts = np.array(cat.points) ax.plot(pts[:, 0], pts[:, 1], pts[:, 2], color='black', linewidth=0.8, alpha=0.4) # 2. Plot Strips (Meshes) for i, mesh in enumerate(meshes_3d): verts = np.array([mesh.vertex_coordinates(v) for v in mesh.vertices()]) faces = [mesh.face_vertices(f) for f in mesh.faces()] triangles = [] for face in faces: if len(face) == 4: triangles.append([face[0], face[1], face[2]]) triangles.append([face[0], face[2], face[3]]) else: triangles.append(face) # Alternating colors like in app.py color = 'lightblue' if i % 4 < 2 else 'orange' if triangles: ax.plot_trisurf(verts[:, 0], verts[:, 1], verts[:, 2], triangles=triangles, color=color, alpha=0.7, linewidth=0.1, edgecolor='black') # Axis settings x_span = config['xy_span'][0] y_span = config['xy_span'][1] hc = config['max_rise'] ax.set_xlim(x_span) ax.set_ylim(y_span) ax.set_zlim([-0.5, hc + 1.0]) # Ensure equal scaling dx = x_span[1] - x_span[0] dy = y_span[1] - y_span[0] dz = (hc + 1.0) - (-0.5) ax.set_box_aspect((dx, dy, dz)) ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_zlabel('Z') ax.set_title(f"{title} - {elevation}/{azimuth}") ax.view_init(elev=elevation, azim=azimuth) plt.savefig(output_path, dpi=150, bbox_inches='tight') plt.close(fig) def plot_flat_patterns_matplotlib(meshes_flat, distortions, output_path, title="Flat Patterns"): """Render the unrolled flat strips in 2D.""" fig, ax = plt.subplots(figsize=(12, 10)) all_d = [d for sublist in distortions for d in sublist] if distortions else [] max_d = max(all_d) if all_d else 0.01 if max_d < 1e-6: max_d = 0.01 for i, (mesh, dists) in enumerate(zip(meshes_flat, distortions)): if not mesh: continue verts = np.array([mesh.vertex_coordinates(v) for v in mesh.vertices()]) faces = [mesh.face_vertices(f) for f in mesh.faces()] for f_idx, face in enumerate(faces): poly_verts = verts[face][:, :2] # Project to XY # Color by distortion relative to max_d d_val = dists[f_idx] if f_idx < len(dists) else 0 # Use Viridis mapping color_intensity = min(1.0, d_val / max_d) facecolor = plt.cm.viridis(color_intensity) polygon = plt.Polygon(poly_verts, closed=True, fill=True, facecolor=facecolor, edgecolor='black', linewidth=0.2, alpha=0.8) ax.add_patch(polygon) ax.set_aspect('equal') ax.autoscale_view() ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_title(f"{title} (Max Distortion: {max_d:.4f})") # Add a simple colorbar sm = plt.cm.ScalarMappable(cmap=plt.cm.viridis, norm=mcolors.Normalize(vmin=0, vmax=max_d)) plt.colorbar(sm, ax=ax, label='Distortion') plt.savefig(output_path, dpi=150, bbox_inches='tight') plt.close(fig) def plot_plywood_layout_matplotlib(packed_meshes, sheet_w, sheet_h, used_dims, output_path, title="Plywood Layout"): """Render the packed flat strips on a sheet of plywood.""" fig, ax = plt.subplots(figsize=(12, 6)) # 1. Draw Provided Sheet Boundary sheet_rect = plt.Rectangle((0, 0), sheet_w, sheet_h, linewidth=1, edgecolor='blue', facecolor='burlywood', alpha=0.05, linestyle='--') ax.add_patch(sheet_rect) # 2. Draw Required Area req_rect = plt.Rectangle((0, 0), used_dims[0], used_dims[1], linewidth=2, edgecolor='green', facecolor='green', alpha=0.1) ax.add_patch(req_rect) # 3. Draw Packed Meshes for mesh in packed_meshes: verts = np.array([mesh.vertex_coordinates(v) for v in mesh.vertices()]) faces = [mesh.face_vertices(f) for f in mesh.faces()] for face in faces: poly_verts = verts[face][:, :2] polygon = plt.Polygon(poly_verts, closed=True, fill=True, facecolor='white', edgecolor='black', linewidth=0.5, alpha=0.9) ax.add_patch(polygon) ax.set_aspect('equal') max_w = max(sheet_w, used_dims[0]) max_h = max(sheet_h, used_dims[1]) ax.set_xlim(-0.05, max_w + 0.05) ax.set_ylim(-0.05, max_h + 0.05) ax.set_xlabel('Width (m)') ax.set_ylabel('Height (m)') ax.set_title(title) plt.savefig(output_path, dpi=150, bbox_inches='tight') plt.close(fig) # ----------------------------------------------------------------------------- # Core Execution Logic # ----------------------------------------------------------------------------- def run_headless_workflow(): # Create main output directory timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") root_output = os.path.join("outputs", f"run_{timestamp}") os.makedirs(root_output, exist_ok=True) # Global log log_file = os.path.join(root_output, "execution.log") logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(log_file), logging.StreamHandler() ] ) logger = logging.getLogger() logger.info(f"Starting headless workflow. Output directory: {root_output}") results = [] for case in TEST_CASES: name = case['name'] logger.info(f"--- Processing Test Case: {name} ---") case_dir = os.path.join(root_output, name) os.makedirs(case_dir, exist_ok=True) # Prepare parameters span_x = case['span_x'] span_y = case['span_y'] config = { 'xy_span': [[0.0, span_x], [0.0, span_y]], 'thickness': case['thick'], 'max_rise': case['rise'], 'discretisation_level': 40, 'form_discretisation': case['discr'], 'form_discretisation_x': case.get('discr_x', case['discr']), 'form_discretisation_y': case.get('discr_y', case['discr']), 'solver': 'IPOPT', 'support_type': 'corners', 'vault_type': 'fan' } try: # 1. TNA Analysis logger.info(f" Running TNA simulation...") sim_data = run_tna_simulation(config) # 2. Geometry Calculation logger.info(f" Generating corrugated geometry...") xm, ym = span_x / 2.0, span_y / 2.0 center_coords = (xm, ym) # Check Symmetry asym_min = calculate_asymmetry(sim_data['form_min'], xm, ym) asym_max = calculate_asymmetry(sim_data['form_max'], xm, ym) max_asym = max(asym_min, asym_max) # Determine safe cut radius n_spokes_total = config.get('form_discretisation_x', 10) + config.get('form_discretisation_y', 10) + 1 quad_full_cats = generate_envelope_catenaries( config, n_spokes=n_spokes_total, n_points=config['form_discretisation'] + 1, corner_cut_radius=0.0 ) # Flatten for safe radius calculation all_full_cats = [c for q in quad_full_cats for c in q] max_safe_radius = compute_max_safe_cut_radius(all_full_cats) corner_cut = max_safe_radius * case.get('corner_cut_ratio', 0.5) logger.info(f" Max safe cut radius: {max_safe_radius:.4f}. Using: {corner_cut:.4f}") quadrant_catenaries = generate_envelope_catenaries( config, n_spokes=n_spokes_total, n_points=config['form_discretisation'] + 1, corner_cut_radius=corner_cut ) meshes_3d, meshes_flat, distortions = [], [], [] all_cats_flat = [] for quad_cats in quadrant_catenaries: m3d, mflat, dists = generate_vault_meshes(quad_cats, flat_z_offset=-5.0) meshes_3d.extend(m3d) meshes_flat.extend(mflat) distortions.extend(dists) all_cats_flat.extend(quad_cats) # 3. Packing & Layout logger.info(f" Packing strips onto plywood (Quadrant 1 only)...") # Calculate strips per quadrant. Each quadrant has n_spokes_total, resulting in n_spokes_total - 1 strips. strips_per_quad = n_spokes_total - 1 valid_flat_meshes = [m for m in meshes_flat[:strips_per_quad] if m] packed_meshes, pack_success, used_dims = pack_strips( valid_flat_meshes, SHEET_W, SHEET_H, margin=SHEET_MARGIN, optimize_rotation=True ) used_area = 0 from code.packing import get_mesh_2d_bbox for m in packed_meshes: min_x, min_y, max_x, max_y = get_mesh_2d_bbox(m) used_area += (max_x - min_x) * (max_y - min_y) # Utilization of the REQUIRED area required_area = used_dims[0] * used_dims[1] utilization = (used_area / required_area) * 100 if required_area > 0 else 0 # 4. Statistics & Logging all_d = [item for sublist in distortions for item in sublist] avg_dist = np.mean(all_d) if all_d else 0 max_dist = max(all_d) if all_d else 0 stats = { "name": name, "converged": True, "num_strips": len(meshes_3d), "avg_distortion": avg_dist, "max_distortion": max_dist, "asymmetry_index": max_asym, "packing_success": pack_success, "strips_packed": f"{len(packed_meshes)}/{len(valid_flat_meshes)}", "min_sheet_size": f"{used_dims[0]:.2f} x {used_dims[1]:.2f} m", "utilization": f"{utilization:.2f}%" } results.append(stats) logger.info(f" Results: Strips={stats['num_strips']}, AvgDist={avg_dist:.6f}, MaxDist={max_dist:.6f}, Asym={max_asym:.4f}") logger.info(f" Packing: {stats['strips_packed']}, Required: {stats['min_sheet_size']}, Utilization={stats['utilization']}") # Save stats to text file with open(os.path.join(case_dir, "stats.txt"), "w") as f: for k, v in case.items(): f.write(f"{k}: {v}\n") f.write("---\n") for k, v in stats.items(): f.write(f"{k}: {v}\n") # 5. Renderings logger.info(f" Generating renderings...") # Structural Plots (4 views) views = [ ("isometric", 30, -135), ("top", 90, -90), ("front", 0, -90), ("right", 0, 0) ] for v_name, elev, azim in views: # Structural fig_struct = create_structural_plot(sim_data, config, elevation=elev, azimuth=azim, title=v_name.capitalize()) fig_struct.savefig(os.path.join(case_dir, f"structural_{v_name}.png"), dpi=150, bbox_inches='tight') plt.close(fig_struct) # Geometry plot_geometry_matplotlib( meshes_3d, all_cats_flat, config, os.path.join(case_dir, f"geometry_{v_name}.png"), title=f"Corrugated - {v_name.capitalize()}", elevation=elev, azimuth=azim ) # Flat Patterns plot_flat_patterns_matplotlib( meshes_flat, distortions, os.path.join(case_dir, "flat_patterns.png") ) # Plywood Layout plot_plywood_layout_matplotlib( packed_meshes, SHEET_W, SHEET_H, used_dims, os.path.join(case_dir, "plywood_layout.png"), title=f"Plywood Layout - Required: {stats['min_sheet_size']} ({stats['utilization']} used)" ) logger.info(f" Case {name} completed successfully.") logger.info(f" Case {name} completed successfully.") except Exception as e: logger.error(f" FAILED Case {name}: {str(e)}") logger.error(traceback.format_exc()) results.append({"name": name, "converged": False, "error": str(e)}) # Final Summary logger.info("--- WORKFLOW COMPLETE ---") summary_file = os.path.join(root_output, "summary.txt") with open(summary_file, "w") as f: f.write(f"Headless Run Summary - {timestamp}\n") f.write("="*40 + "\n") for res in results: status = "PASS" if res.get('converged') else "FAIL" f.write(f"[{status}] {res['name']}\n") if res.get('converged'): f.write(f" Strips: {res['num_strips']}, Max Dist: {res['max_distortion']:.6f}, Max Asym: {res['asymmetry_index']:.4f}\n") f.write(f" Packing: {res['strips_packed']}, Min Sheet: {res['min_sheet_size']}, Utilization: {res['utilization']}\n") else: f.write(f" Error: {res.get('error')}\n") logger.info(f"Summary saved to {summary_file}") if __name__ == "__main__": run_headless_workflow()