curvedarch / code /vault_logic.py
jpata's picture
labeling
5a8852a
Raw
History Blame Contribute Delete
30.6 kB
import math
from compas.geometry import Point, Polyline, Vector, intersection_circle_circle_xy
from compas.datastructures import Mesh
from compas_tna.diagrams import FormDiagram
from code.vault_shared import fanvault_middle_hc, crossvault_middle_hc
def _get_vector(v1, v2):
return (v2['x'] - v1['x'], v2['y'] - v1['y'])
def _normalize(v):
L = math.sqrt(v[0]**2 + v[1]**2)
if L == 0: return (0, 0)
return (v[0]/L, v[1]/L)
def _dot(v1, v2):
return v1[0]*v2[0] + v1[1]*v2[1]
def extract_spokes(diagram, target_corner_coords=(0.0, 0.0), center_coords=(5.0, 5.0)):
target_corner = None
for v in diagram.vertices():
x, y = diagram.vertex_attributes(v, names=['x', 'y'])
if abs(x - target_corner_coords[0]) < 1e-6 and abs(y - target_corner_coords[1]) < 1e-6:
target_corner = v
break
if target_corner is None: return []
spokes = []
neighbors = diagram.vertex_neighbors(target_corner)
for n in neighbors:
spoke = [target_corner, n]
curr, prev = n, target_corner
while True:
v_curr = diagram.vertex_attributes(curr)
if abs(v_curr['x'] - center_coords[0]) < 1e-6 or abs(v_curr['y'] - center_coords[1]) < 1e-6:
break
next_neighbors = diagram.vertex_neighbors(curr)
best_n, max_dot = None, -2.0
vec_prev = _normalize(_get_vector(diagram.vertex_attributes(prev), v_curr))
for nn in next_neighbors:
if nn == prev: continue
vec_next = _normalize(_get_vector(v_curr, diagram.vertex_attributes(nn)))
d = _dot(vec_prev, vec_next)
if d > max_dot:
max_dot, best_n = d, nn
if best_n is None or max_dot < 0.5: break
prev, curr = curr, best_n
spoke.append(curr)
spokes.append(spoke)
return spokes
def cut_polyline_at_radius(pts, center_pt, radius):
new_pts = []
cut_done = False
cx, cy = center_pt.x, center_pt.y
for i in range(len(pts) - 1):
p1, p2 = pts[i], pts[i+1]
if not cut_done:
d1 = math.hypot(p1.x - cx, p1.y - cy)
d2 = math.hypot(p2.x - cx, p2.y - cy)
if d1 <= radius and d2 >= radius:
dx, dy = p2.x - p1.x, p2.y - p1.y
qx, qy = p1.x - cx, p1.y - cy
A = dx**2 + dy**2
B = 2 * (qx*dx + qy*dy)
C = qx**2 + qy**2 - radius**2
if A > 1e-9:
det = B**2 - 4*A*C
if det >= 0:
t1 = (-B + math.sqrt(det)) / (2*A)
t2 = (-B - math.sqrt(det)) / (2*A)
t = t1 if 0 <= t1 <= 1 else t2
if 0 <= t <= 1:
new_pts.append(Point(p1.x + t*dx, p1.y + t*dy, p1.z + t*(p2.z - p1.z)))
new_pts.append(p2)
cut_done = True
continue
else:
new_pts.append(p2)
return new_pts if new_pts else pts
def develop_strip_to_plane(poly1_3d, poly2_3d, start_point_on_plane, initial_unroll_vec):
if len(poly1_3d.points) != len(poly2_3d.points) or len(poly1_3d.points) < 2:
return None, []
N = len(poly1_3d.points)
z_plane = start_point_on_plane.z
flat_vertices, flat_faces = [], []
quad_distortions = []
xy_plane_normal = Vector(0, 0, 1)
P0_3d, Q0_3d = poly1_3d.points[0], poly2_3d.points[0]
P_curr_flat = start_point_on_plane.copy()
flat_vertices.append(P_curr_flat)
dist_p0q0 = P0_3d.distance_to_point(Q0_3d)
unroll_dir_xy = Vector(initial_unroll_vec.x, initial_unroll_vec.y, 0)
if unroll_dir_xy.length < 1e-6: unroll_dir_xy = Vector(1,0,0)
else: unroll_dir_xy.unitize()
rung_dir_xy = Vector(-unroll_dir_xy.y, unroll_dir_xy.x, 0)
Q_curr_flat = P_curr_flat + rung_dir_xy * dist_p0q0
Q_curr_flat.z = z_plane
flat_vertices.append(Q_curr_flat)
P_prev_flat = None
for j in range(N - 1):
P_curr_3d, P_next_3d = poly1_3d.points[j], poly1_3d.points[j+1]
Q_curr_3d, Q_next_3d = poly2_3d.points[j], poly2_3d.points[j+1]
d_pc_pn = P_curr_3d.distance_to_point(P_next_3d)
d_qc_pn = Q_curr_3d.distance_to_point(P_next_3d)
intersections_P = intersection_circle_circle_xy(
((P_curr_flat, xy_plane_normal), d_pc_pn),
((Q_curr_flat, xy_plane_normal), d_qc_pn)
)
if not intersections_P: return None, []
P_next_flat_chosen_xy = intersections_P[0]
if len(intersections_P) > 1:
fwd_dir = unroll_dir_xy if P_prev_flat is None else Vector.from_start_end(P_prev_flat, P_curr_flat).unitized()
v0 = Vector(intersections_P[0][0]-P_curr_flat.x, intersections_P[0][1]-P_curr_flat.y, 0).unitized()
v1 = Vector(intersections_P[1][0]-P_curr_flat.x, intersections_P[1][1]-P_curr_flat.y, 0).unitized()
P_next_flat_chosen_xy = intersections_P[0] if v0.dot(fwd_dir) >= v1.dot(fwd_dir) else intersections_P[1]
P_next_flat = Point(P_next_flat_chosen_xy[0], P_next_flat_chosen_xy[1], z_plane)
d_qc_qn = Q_curr_3d.distance_to_point(Q_next_3d)
d_pn_qn = P_next_3d.distance_to_point(Q_next_3d)
intersections_Q = intersection_circle_circle_xy(
((Q_curr_flat, xy_plane_normal), d_qc_qn),
((P_next_flat, xy_plane_normal), d_pn_qn)
)
if not intersections_Q: return None, []
Q_next_flat_chosen_xy = intersections_Q[0]
if len(intersections_Q) > 1:
rung_prev_dir = Vector.from_start_end(P_curr_flat, Q_curr_flat).unitized()
r0 = Vector(intersections_Q[0][0]-P_next_flat.x, intersections_Q[0][1]-P_next_flat.y, 0).unitized()
r1 = Vector(intersections_Q[1][0]-P_next_flat.x, intersections_Q[1][1]-P_next_flat.y, 0).unitized()
Q_next_flat_chosen_xy = intersections_Q[0] if r0.dot(rung_prev_dir) >= r1.dot(rung_prev_dir) else intersections_Q[1]
Q_next_flat = Point(Q_next_flat_chosen_xy[0], Q_next_flat_chosen_xy[1], z_plane)
diag_3d_length = P_curr_3d.distance_to_point(Q_next_3d)
diag_flat_length = P_curr_flat.distance_to_point(Q_next_flat)
current_quad_distortion = 0.0
if diag_3d_length > 1e-9:
current_quad_distortion = abs(diag_3d_length - diag_flat_length) / diag_3d_length
elif diag_flat_length > 1e-9:
current_quad_distortion = 10.0
quad_distortions.append(current_quad_distortion)
flat_vertices.extend([P_next_flat, Q_next_flat])
idx_Pc, idx_Qc = 2*j, 2*j + 1
idx_Pn, idx_Qn = len(flat_vertices)-2, len(flat_vertices)-1
flat_faces.append([idx_Pc, idx_Pn, idx_Qn, idx_Qc])
P_prev_flat = P_curr_flat
P_curr_flat = P_next_flat
Q_curr_flat = Q_next_flat
return Mesh.from_vertices_and_faces(flat_vertices, flat_faces), quad_distortions
def get_alternating_catenaries(form_min, form_max, corner_cut_radius=0.5, center_coords=(5.0, 5.0)):
if isinstance(form_min, str):
form_min = FormDiagram.from_json(form_min)
if isinstance(form_max, str):
form_max = FormDiagram.from_json(form_max)
spokes_min_ids = extract_spokes(form_min, center_coords=center_coords)
spokes_max_ids = extract_spokes(form_max, center_coords=center_coords)
def get_spoke_angle(spoke, diagram):
c_coords = diagram.vertex_attributes(spoke[0], names=['x', 'y'])
n_coords = diagram.vertex_attributes(spoke[1], names=['x', 'y'])
return math.atan2(n_coords[1] - c_coords[1], n_coords[0] - c_coords[0])
spokes_min_ids.sort(key=lambda s: get_spoke_angle(s, form_min))
spokes_max_ids.sort(key=lambda s: get_spoke_angle(s, form_max))
catenaries = []
n_spokes = min(len(spokes_min_ids), len(spokes_max_ids))
for i in range(n_spokes):
if i % 2 == 0:
ids, diagram = spokes_max_ids[i], form_max
else:
ids, diagram = spokes_min_ids[i], form_min
pts = [Point(*diagram.vertex_coordinates(v)) for v in ids]
if corner_cut_radius > 1e-6 and len(pts) > 1:
pts = cut_polyline_at_radius(pts, pts[0], corner_cut_radius)
catenaries.append(Polyline(pts))
return catenaries
def compute_max_safe_cut_radius(catenaries):
"""
Computes the maximum radius that can be applied to all catenaries
while ensuring they all retain the same number of nodes.
"""
if not catenaries:
return 0.0
# For each catenary, find the distance to the furthest point that would
# still leave at least 2 points (the minimum needed for a line segment).
# However, to be safe and consistent, we want to find the radius
# that doesn't "jump" a node on any catenary.
# The limit is the distance to the 2nd node of the "shortest" spoke
min_dist_to_second_node = float('inf')
for cat in catenaries:
if len(cat.points) < 2:
continue
p0 = cat.points[0]
p1 = cat.points[1]
dist = math.hypot(p1.x - p0.x, p1.y - p0.y)
if dist < min_dist_to_second_node:
min_dist_to_second_node = dist
# We return slightly less than the absolute limit to avoid precision issues
return max(0.0, min_dist_to_second_node - 0.01)
def generate_envelope_catenaries(config, n_spokes=10, n_points=20, corner_cut_radius=0.0):
"""
Generates a fixed number of catenaries directly from the vault envelope
instead of using the TNA thrust diagrams.
"""
xy_span = config['xy_span']
thickness = config['thickness']
hc = config['max_rise']
v_type = config['vault_type']
x0, x1 = xy_span[0]
y0, y1 = xy_span[1]
xm, ym = (x0 + x1) / 2, (y0 + y1) / 2
# We follow the existing logic of only using the primary corner (x0, y0)
xc, yc = x0, y0
dx = xm - xc
dy = ym - yc
# Split n_spokes between the two boundary edges of the quadrant
# nx + ny = n_spokes - 1
total_len = abs(dx) + abs(dy)
nx = max(1, int(round((n_spokes - 1) * abs(dx) / total_len)))
ny = (n_spokes - 1) - nx
# Quadrant corners
corners = [
(x0, y0), (x1, y0), (x1, y1), (x0, y1)
]
all_catenaries = []
global_spoke_idx = 0
for ci, (xc, yc) in enumerate(corners):
# Directions towards center lines for this quadrant
qdx = xm - xc
qdy = ym - yc
qpts = []
# Edge 1: Along the boundary where x varies (at y=ym)
for i in range(nx + 1):
t = i / nx
qpts.append((xc + t * qdx, ym))
# Edge 2: Along the boundary where y varies (at x=xm)
for i in range(1, ny + 1):
t = i / ny
qpts.append((xm, ym - t * qdy))
quadrant_cats = []
for si, (px, py) in enumerate(qpts):
pts = []
for j in range(n_points):
u = j / (n_points - 1)
x = xc + u * (px - xc)
y = yc + u * (py - yc)
# Z from envelope
if v_type == 'fan':
z_mid = fanvault_middle_hc([x], [y], [x0, x1], [y0, y1], hc)[0]
else:
z_mid = crossvault_middle_hc([x], [y], [x0, x1], [y0, y1], hc)[0]
# Alternate Z based on intrados and extrados
if si % 2 == 0:
z = z_mid - thickness / 2
else:
z = z_mid + thickness / 2
pts.append(Point(x, y, z))
if corner_cut_radius > 1e-6:
pts = cut_polyline_at_radius(pts, Point(xc, yc, pts[0].z), corner_cut_radius)
quadrant_cats.append(Polyline(pts))
global_spoke_idx += 1
all_catenaries.append(quadrant_cats)
return all_catenaries
def generate_support_beams(config, n_spokes=10, ply_thickness=0.012):
"""
Generates meshes for cross-shaped support beams running along the centerlines.
The top edge matches the zigzag pattern of the corrugation,
offset by ply_thickness to support the underside.
The bottom edge is flat at z=0.
"""
xy_span = config['xy_span']
thickness = config['thickness']
hc = config['max_rise']
v_type = config['vault_type']
x0, x1 = xy_span[0]
y0, y1 = xy_span[1]
xm, ym = (x0 + x1) / 2, (y0 + y1) / 2
# Calculate how points are distributed, same logic as generate_envelope_catenaries
dx = xm - x0
dy = ym - y0
total_len = abs(dx) + abs(dy)
nx = max(1, int(round((n_spokes - 1) * abs(dx) / total_len)))
ny = (n_spokes - 1) - nx
# --- Generate X-Beam (along y=ym, from x0 to x1) ---
x_beam_verts = []
x_beam_faces = []
# First half of X-Beam: x0 to xm (from Corner 0's spoke ends)
pts_x_half1 = []
for i in range(nx + 1):
t = i / nx
px = x0 + t * dx
z_mid = fanvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0]
# Same alternating logic: si corresponds to the point index on this edge
si = i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness # Offset for plywood material thickness
pts_x_half1.append((px, z_top))
# Second half of X-Beam: xm to x1 (from Corner 1's spoke ends)
# Important: Ensure the zigzag aligns at xm.
# Corner 1 spokes on Edge 1 go from x1 to xm. We need to go from xm to x1, or construct it matching.
pts_x_half2 = []
dx_corner1 = xm - x1 # negative
for i in range(1, nx + 1):
t = i / nx
px = x1 + (1.0 - t) * dx_corner1 # Going from xm towards x1
z_mid = fanvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0]
# In Corner 1, the spoke index si for points on Edge 1 is also i.
# But we are iterating backwards from xm (which was si=nx).
# To match exactly, the point at px corresponds to si = nx - i.
si = nx - i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness # Offset for plywood material thickness
pts_x_half2.append((px, z_top))
full_x_profile = pts_x_half1 + pts_x_half2
for i, (px, pz) in enumerate(full_x_profile):
x_beam_verts.append([px, ym, pz]) # Top
x_beam_verts.append([px, ym, 0.0]) # Bottom
if i > 0:
idx = i * 2
x_beam_faces.append([idx-2, idx, idx+1, idx-1])
x_beam_mesh = Mesh.from_vertices_and_faces(x_beam_verts, x_beam_faces)
# --- Generate Y-Beam (along x=xm, from y0 to y1) ---
y_beam_verts = []
y_beam_faces = []
# First half of Y-Beam: y0 to ym (from Corner 0's spoke ends on Edge 2)
pts_y_half1 = []
# In Corner 0, Edge 2 starts from ym and goes down to y0. But let's build from y0 to ym.
for i in range(ny, -1, -1):
t = i / ny
py = ym - t * dy
z_mid = fanvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0]
# For Corner 0, Edge 2, spoke index is nx + i (since i goes 1 to ny).
# Wait, if i=0, it's the center point.
# In generate_envelope_catenaries: for i in range(1, ny+1): t = i/ny; py = ym - t*dy
# So the center point (xm, ym) is actually handled by Edge 1 (si=nx).
# To make it continuous, we can use si = nx + i
si = nx + i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness # Offset for plywood material thickness
pts_y_half1.append((py, z_top))
# Second half of Y-Beam: ym to y1 (from Corner 3 (x0, y1) or Corner 2 (x1, y1))
pts_y_half2 = []
# Corner 3: x0, y1. dy_c3 = ym - y1 (negative)
# Edge 2 for Corner 3 would be on x=xm, varying y.
dy_c3 = ym - y1
for i in range(1, ny + 1):
t = i / ny
py = ym - t * dy_c3 # This goes from ym towards y1
z_mid = fanvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0]
# In Corner 3, to match symmetry, it's si = nx + i
si = nx + i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness # Offset for plywood material thickness
pts_y_half2.append((py, z_top))
full_y_profile = pts_y_half1 + pts_y_half2
for i, (py, pz) in enumerate(full_y_profile):
y_beam_verts.append([xm, py, pz]) # Top
y_beam_verts.append([xm, py, 0.0]) # Bottom
if i > 0:
idx = i * 2
y_beam_faces.append([idx-2, idx, idx+1, idx-1])
y_beam_mesh = Mesh.from_vertices_and_faces(y_beam_verts, y_beam_faces)
return [x_beam_mesh, y_beam_mesh]
def generate_perimeter_beams(config, n_spokes=10, ply_thickness=0.012):
"""
Generates meshes for perimeter support beams running along the 4 outer edges.
The top edge matches the boundary spokes of the fan quadrants.
The bottom edge is flat at z=0.
"""
xy_span = config['xy_span']
thickness = config['thickness']
hc = config['max_rise']
v_type = config['vault_type']
x0, x1 = xy_span[0]
y0, y1 = xy_span[1]
xm, ym = (x0 + x1) / 2, (y0 + y1) / 2
# Resolution for the beam curve (matching n_points in generate_envelope_catenaries)
# n_points is passed as discr+1 in app.py. We'll use a reasonable default or pass it.
n_points = 21 # Default resolution
beams = []
# Helper to generate a beam mesh along an edge
def create_edge_beam(p_start, p_end, quadrant_indices, spoke_indices):
verts = []
faces = []
# p_start to p_mid and p_mid to p_end
# Each segment comes from one quadrant's boundary spoke
for q_idx, s_idx in zip(quadrant_indices, spoke_indices):
# Deterministic corners based on generate_envelope_catenaries
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
xc, yc = corners[q_idx]
# Reconstruct the boundary spoke points
# Edge 1 (si=0) goes from xc,yc to xc,ym (or x1,ym etc)
# Edge 2 (si=nx+ny) goes from xc,yc to xm,yc
# We need to know if this segment goes from corner to middle or middle to corner
# Let's simplify: we know the start and end of the segment.
# Segment 1: p_start to p_mid. Segment 2: p_mid to p_end.
if q_idx in [0, 1, 2, 3]: # Just a placeholder for logic
pass
# Simplified: Just sample the envelope along the edge and alternate z based on the global spoke index
# But wait, the perimeter is ONLY the boundary spokes (si=0 or si=nx+ny).
# So we just need the z of THAT specific spoke.
pass
# Actually, let's just do it manually for the 4 edges for clarity.
dx = xm - x0
dy = ym - y0
total_len = abs(dx) + abs(dy)
nx = max(1, int(round((n_spokes - 1) * abs(dx) / total_len)))
ny = (n_spokes - 1) - nx
# 1. Beam along y = y0 (x0 to x1)
verts, faces = [], []
si = nx + ny
z_bottom = (-thickness / 2 if si % 2 == 0 else thickness / 2) - ply_thickness
# Half 1: x0 to xm (Quad 0, si = nx+ny)
for i in range(n_points):
u = i / (n_points - 1)
px = x0 + u * (xm - x0)
z_mid = fanvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[px, y0, z_top], [px, y0, z_bottom]])
# Half 2: xm to x1 (Quad 1, si = nx+ny)
for i in range(1, n_points):
u = i / (n_points - 1)
px = xm + u * (x1 - xm)
z_mid = fanvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[px, y0, z_top], [px, y0, z_bottom]])
for i in range(len(verts)//2 - 1):
idx = i * 2
faces.append([idx, idx+2, idx+3, idx+1])
beams.append(Mesh.from_vertices_and_faces(verts, faces))
# 2. Beam along y = y1 (x0 to x1)
verts, faces = [], []
si = nx + ny
z_bottom = (-thickness / 2 if si % 2 == 0 else thickness / 2) - ply_thickness
# Half 1: x0 to xm (Quad 3, si = nx+ny)
for i in range(n_points):
u = i / (n_points - 1)
px = x0 + u * (xm - x0)
z_mid = fanvault_middle_hc([px], [y1], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [y1], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[px, y1, z_top], [px, y1, z_bottom]])
# Half 2: xm to x1 (Quad 2, si = nx+ny)
for i in range(1, n_points):
u = i / (n_points - 1)
px = xm + u * (x1 - xm)
z_mid = fanvault_middle_hc([px], [y1], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [y1], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[px, y1, z_top], [px, y1, z_bottom]])
for i in range(len(verts)//2 - 1):
idx = i * 2
faces.append([idx, idx+2, idx+3, idx+1])
beams.append(Mesh.from_vertices_and_faces(verts, faces))
# 3. Beam along x = x0 (y0 to y1)
verts, faces = [], []
si = 0
z_bottom = (-thickness / 2 if si % 2 == 0 else thickness / 2) - ply_thickness
# Half 1: y0 to ym (Quad 0, si = 0)
for i in range(n_points):
u = i / (n_points - 1)
py = y0 + u * (ym - y0)
z_mid = fanvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[x0, py, z_top], [x0, py, z_bottom]])
# Half 2: ym to y1 (Quad 3, si = 0)
for i in range(1, n_points):
u = i / (n_points - 1)
py = ym + u * (y1 - ym)
z_mid = fanvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[x0, py, z_top], [x0, py, z_bottom]])
for i in range(len(verts)//2 - 1):
idx = i * 2
faces.append([idx, idx+2, idx+3, idx+1])
beams.append(Mesh.from_vertices_and_faces(verts, faces))
# 4. Beam along x = x1 (y0 to y1)
verts, faces = [], []
si = 0
z_bottom = (-thickness / 2 if si % 2 == 0 else thickness / 2) - ply_thickness
# Half 1: y0 to ym (Quad 1, si = 0)
for i in range(n_points):
u = i / (n_points - 1)
py = y0 + u * (ym - y0)
z_mid = fanvault_middle_hc([x1], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([x1], [py], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[x1, py, z_top], [x1, py, z_bottom]])
# Half 2: ym to y1 (Quad 2, si = 0)
for i in range(1, n_points):
u = i / (n_points - 1)
py = ym + u * (y1 - ym)
z_mid = fanvault_middle_hc([x1], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([x1], [py], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
verts.extend([[x1, py, z_top], [x1, py, z_bottom]])
for i in range(len(verts)//2 - 1):
idx = i * 2
faces.append([idx, idx+2, idx+3, idx+1])
beams.append(Mesh.from_vertices_and_faces(verts, faces))
return beams
def generate_beam_patterns(config, n_spokes=10, ply_thickness=0.012):
"""
Generates 2D flattened meshes for one half of each support and perimeter beam.
These are intended for the plywood layout.
"""
xy_span = config['xy_span']
thickness = config['thickness']
hc = config['max_rise']
v_type = config['vault_type']
x0, x1 = xy_span[0]
y0, y1 = xy_span[1]
xm, ym = (x0 + x1) / 2, (y0 + y1) / 2
dx = xm - x0
dy = ym - y0
total_len = abs(dx) + abs(dy)
nx = max(1, int(round((n_spokes - 1) * abs(dx) / total_len)))
ny = (n_spokes - 1) - nx
n_points = 21
beam_patterns = []
# 1. Support Beam X (Half)
pts_x_half = []
for i in range(nx + 1):
t = i / nx
px = x0 + t * dx
z_mid = fanvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [ym], [x0, x1], [y0, y1], hc)[0]
si = i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
pts_x_half.append((px - x0, z_top))
verts, faces = [], []
for i, (lx, pz) in enumerate(pts_x_half):
verts.append([lx, pz, 0.0])
verts.append([lx, 0.0, 0.0])
if i > 0:
idx = i * 2
faces.append([idx-2, idx, idx+1, idx-1])
m_sup_x = Mesh.from_vertices_and_faces(verts, faces)
m_sup_x.attributes['name'] = "SUP_X"
beam_patterns.append(m_sup_x)
# 2. Support Beam Y (Half)
pts_y_half = []
for i in range(ny + 1):
t = i / ny
py = ym - t * dy
z_mid = fanvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([xm], [py], [x0, x1], [y0, y1], hc)[0]
si = nx + i
z_top = z_mid - thickness / 2 if si % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
pts_y_half.append((ym - py, z_top))
verts, faces = [], []
for i, (ly, pz) in enumerate(pts_y_half):
verts.append([ly, pz, 0.0])
verts.append([ly, 0.0, 0.0])
if i > 0:
idx = i * 2
faces.append([idx-2, idx, idx+1, idx-1])
m_sup_y = Mesh.from_vertices_and_faces(verts, faces)
m_sup_y.attributes['name'] = "SUP_Y"
beam_patterns.append(m_sup_y)
# 3. Perimeter Beam X-Edge (Half)
z_perim_bottom = (-thickness / 2 if (nx + ny) % 2 == 0 else thickness / 2) - ply_thickness
pts_px_half = []
for i in range(n_points):
u = i / (n_points - 1)
px = x0 + u * (xm - x0)
z_mid = fanvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([px], [y0], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if (nx + ny) % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
pts_px_half.append((px - x0, z_top))
verts, faces = [], []
for i, (lx, pz) in enumerate(pts_px_half):
verts.append([lx, pz, 0.0])
verts.append([lx, z_perim_bottom, 0.0])
if i > 0:
idx = i * 2
faces.append([idx-2, idx, idx+1, idx-1])
m_per_x = Mesh.from_vertices_and_faces(verts, faces)
m_per_x.attributes['name'] = "PERM_X"
beam_patterns.append(m_per_x)
# 4. Perimeter Beam Y-Edge (Half)
z_perim_bottom_y = (-thickness / 2 if 0 % 2 == 0 else thickness / 2) - ply_thickness # si=0 for Y perimeter
pts_py_half = []
for i in range(n_points):
u = i / (n_points - 1)
py = y0 + u * (ym - y0)
z_mid = fanvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0] if v_type == 'fan' else crossvault_middle_hc([x0], [py], [x0, x1], [y0, y1], hc)[0]
z_top = z_mid - thickness / 2 if 0 % 2 == 0 else z_mid + thickness / 2
z_top -= ply_thickness
pts_py_half.append((py - y0, z_top))
verts, faces = [], []
for i, (ly, pz) in enumerate(pts_py_half):
verts.append([ly, pz, 0.0])
verts.append([ly, z_perim_bottom_y, 0.0])
if i > 0:
idx = i * 2
faces.append([idx-2, idx, idx+1, idx-1])
m_per_y = Mesh.from_vertices_and_faces(verts, faces)
m_per_y.attributes['name'] = "PERM_Y"
beam_patterns.append(m_per_y)
return beam_patterns
def generate_vault_meshes(catenaries, flat_z_offset=-15.0):
three_d_meshes = []
flat_meshes = []
distortions_list = []
num_strips = max(0, len(catenaries) - 1)
for i in range(num_strips):
poly1_3d, poly2_3d = catenaries[i], catenaries[i+1]
# Validation
n1, n2 = len(poly1_3d.points), len(poly2_3d.points)
if n1 != n2:
raise ValueError(
f"Catenary length mismatch at strip {i}: "
f"Left has {n1} points, Right has {n2} points. "
"This usually happens when 'Corner Cut Radius' is too large, "
"causing different numbers of segments to be trimmed from adjacent spokes."
)
# 3D Mesh
verts_3d = poly1_3d.points + poly2_3d.points
faces_3d = []
offset = len(poly1_3d.points)
for j in range(len(poly1_3d.points) - 1):
faces_3d.append([j, j+1, offset + j + 1, offset + j])
three_d_meshes.append(Mesh.from_vertices_and_faces(verts_3d, faces_3d))
# Flat Mesh
start_x, start_y = poly1_3d.points[0].x, poly1_3d.points[0].y
flat_start = Point(start_x, start_y, flat_z_offset)
unroll_vec = Vector.from_start_end(poly1_3d.points[0], poly1_3d.points[1])
flat_mesh, quad_distortions = develop_strip_to_plane(poly1_3d, poly2_3d, flat_start, unroll_vec)
if flat_mesh:
flat_mesh.attributes['name'] = f"S_{i+1:02d}"
flat_meshes.append(flat_mesh)
distortions_list.append(quad_distortions)
return three_d_meshes, flat_meshes, distortions_list