curvedarch / code /packing.py
jpata's picture
Configure for Docker-based deployment on Hugging Face Spaces
3efc7f6
Raw
History Blame Contribute Delete
8.89 kB
import math
import numpy as np
from compas.geometry import Point, Rotation, Translation, Vector, intersection_segment_segment_xy, is_point_in_polygon_xy
def get_mesh_2d_bbox(mesh):
"""Returns (min_x, min_y, max_x, max_y) for a flat mesh."""
vertices = [mesh.vertex_coordinates(v) for v in mesh.vertices()]
xs = [v[0] for v in vertices]
ys = [v[1] for v in vertices]
return min(xs), min(ys), max(xs), max(ys)
def get_mesh_outline(mesh):
"""
Extracts the boundary polygon points for a quad strip mesh.
Assumes vertices are ordered [P0, Q0, P1, Q1, ...]
"""
vertices = [mesh.vertex_coordinates(v) for v in mesh.vertices()]
# P side: 0, 2, 4...
p_side = vertices[0::2]
# Q side: 1, 3, 5... (reversed to close the loop)
q_side = vertices[1::2][::-1]
return p_side + q_side
def polygons_intersect(pts1, pts2, margin=0.0):
"""Checks if two polygons intersect using COMPAS primitives."""
# 1. BBox check (fast)
b1 = [min(p[0] for p in pts1), min(p[1] for p in pts1), max(p[0] for p in pts1), max(p[1] for p in pts1)]
b2 = [min(p[0] for p in pts2), min(p[1] for p in pts2), max(p[0] for p in pts2), max(p[1] for p in pts2)]
# Apply margin to b2 for safety
b2 = [b2[0]-margin, b2[1]-margin, b2[2]+margin, b2[3]+margin]
if b1[2] < b2[0] or b1[0] > b2[2] or b1[3] < b2[1] or b1[1] > b2[3]:
return False
# 2. Segment-Segment intersection
for i in range(len(pts1)):
s1 = (pts1[i], pts1[(i+1)%len(pts1)])
for j in range(len(pts2)):
s2 = (pts2[j], pts2[(j+1)%len(pts2)])
res = intersection_segment_segment_xy(s1, s2)
if res: return True
# 3. Point in Polygon (for containment)
if is_point_in_polygon_xy(pts1[0], pts2): return True
if is_point_in_polygon_xy(pts2[0], pts1): return True
return False
def get_profiles(pts, resolution=0.01):
"""
Computes top and bottom profiles of a polygon at given x-resolution.
Returns (xs, bottoms, tops) arrays.
"""
min_x = min(p[0] for p in pts)
max_x = max(p[0] for p in pts)
# Use linspace for more predictable counts
n_pts = int(round((max_x - min_x) / resolution)) + 1
xs = np.linspace(min_x, max_x, n_pts)
bottoms = np.full(len(xs), float('inf'))
tops = np.full(len(xs), float('-inf'))
for i in range(len(pts)):
p1, p2 = pts[i], pts[(i+1)%len(pts)]
x1, y1 = p1[0], p1[1]
x2, y2 = p2[0], p2[1]
if abs(x2 - x1) < 1e-9:
# Vertical segment: update at one x-bin
idx = int(round((x1 - min_x) / resolution))
if 0 <= idx < len(xs):
bottoms[idx] = min(bottoms[idx], y1, y2)
tops[idx] = max(tops[idx], y1, y2)
continue
# Range of bins covered
x_min, x_max = min(x1, x2), max(x1, x2)
idx_start = int(math.ceil((x_min - min_x) / resolution))
idx_end = int(math.floor((x_max - min_x) / resolution))
for idx in range(max(0, idx_start), min(len(xs), idx_end + 1)):
x = xs[idx]
y = y1 + (y2 - y1) * (x - x1) / (x2 - x1)
bottoms[idx] = min(bottoms[idx], y)
tops[idx] = max(tops[idx], y)
# Fill gaps by interpolation
valid_mask = (bottoms != float('inf'))
if not np.all(valid_mask):
valid_indices = np.where(valid_mask)[0]
if len(valid_indices) > 0:
bottoms = np.interp(xs, xs[valid_mask], bottoms[valid_mask])
tops = np.interp(xs, xs[valid_mask], tops[valid_mask])
return xs, bottoms, tops
def _prepare_items(meshes, optimize_rotation, res):
items = []
for i, m in enumerate(meshes):
m_oriented = m.copy()
vertices = [m_oriented.vertex_coordinates(v) for v in m_oriented.vertices()]
if len(vertices) >= 4:
p_start = Point(*vertices[0])
p_end = Point(*vertices[-2])
vec = Vector.from_start_end(p_start, p_end)
angle = math.atan2(vec.y, vec.x)
R = Rotation.from_axis_and_angle([0, 0, 1], -angle, point=p_start)
m_oriented.transform(R)
variants = []
possible_angles = [0, math.pi/2, math.pi, 3*math.pi/2] if optimize_rotation else [0]
for angle in possible_angles:
m_rot = m_oriented.copy()
if angle != 0:
R = Rotation.from_axis_and_angle([0, 0, 1], angle, point=[0,0,0])
m_rot.transform(R)
mxr, myr, _, _ = get_mesh_2d_bbox(m_rot)
m_rot.transform(Translation.from_vector([-mxr, -myr, 0]))
pts_r = get_mesh_outline(m_rot)
xs_r, b_r, t_r = get_profiles(pts_r, resolution=res)
variants.append({
'mesh': m_rot,
'bottoms': b_r,
'tops': t_r,
'w': max(xs_r),
'n_bins': len(xs_r)
})
items.append({'index': i, 'variants': variants})
return items
def _pack_one_sheet(items, sheet_width, sheet_height, margin, res):
"""Internal helper to pack as many items as possible onto one sheet."""
total_bins = int(math.ceil(sheet_width / res)) + 1
skyline = np.zeros(total_bins)
packed_meshes = []
packed_indices = []
for i, item in enumerate(items):
best_h = float('inf')
best_config = None
for variant in item['variants']:
w = variant['w']
n_bins = variant['n_bins']
max_ox_bin = int(math.floor((sheet_width - 2*margin - w) / res))
for ox_bin in range(max_ox_bin + 1):
v = 0
for j in range(n_bins):
needed = skyline[ox_bin + j] + margin - variant['bottoms'][j]
if needed > v: v = needed
# Check if this item fits within sheet height
peak_h = 0
for j in range(n_bins):
h_at_j = v + variant['tops'][j]
if h_at_j > peak_h: peak_h = h_at_j
if peak_h <= (sheet_height - 2*margin):
if peak_h < best_h:
best_h = peak_h
best_config = (variant, ox_bin, v)
if best_config:
variant, ox_bin, v = best_config
ox = ox_bin * res
placed_m = variant['mesh'].copy()
placed_m.transform(Translation.from_vector([margin + ox, margin + v, 0]))
packed_meshes.append(placed_m)
packed_indices.append(i)
for j in range(variant['n_bins']):
skyline[ox_bin + j] = max(skyline[ox_bin + j], v + variant['tops'][j])
max_h_used = np.max(skyline) if len(packed_meshes) > 0 else 0
max_w_used = 0
if packed_meshes:
for m in packed_meshes:
_, _, mxx, _ = get_mesh_2d_bbox(m)
if mxx > max_w_used: max_w_used = mxx
return packed_meshes, packed_indices, (max_w_used + margin, max_h_used + margin)
def pack_strips(meshes, sheet_width, sheet_height, margin=0.02, optimize_rotation=True):
"""Legacy wrapper for single sheet packing. If height is too small, it still packs everything but returns success=False."""
res = 0.02
items = _prepare_items(meshes, optimize_rotation, res)
items.sort(key=lambda x: x['variants'][0]['w'], reverse=True)
# In legacy mode, we force everything to pack even if it exceeds height,
# so we use a huge height for the internal call then check the result.
packed_meshes, _, used_dims = _pack_one_sheet(items, sheet_width, 1e6, margin, res)
success = used_dims[1] <= sheet_height
return packed_meshes, success, used_dims
def pack_strips_multi(meshes, sheet_width, sheet_height, margin=0.02, optimize_rotation=True):
"""Packs meshes onto as many sheets of fixed size as needed."""
res = 0.02
remaining_items = _prepare_items(meshes, optimize_rotation, res)
remaining_items.sort(key=lambda x: x['variants'][0]['w'], reverse=True)
sheets = []
while remaining_items:
packed_meshes, packed_indices, used_dims = _pack_one_sheet(remaining_items, sheet_width, sheet_height, margin, res)
if not packed_indices:
# Could not fit even a single item on a fresh sheet
# This happens if an item is wider than the sheet
break
sheets.append({
'meshes': packed_meshes,
'dims': used_dims
})
# Remove packed items
remaining_items = [item for i, item in enumerate(remaining_items) if i not in packed_indices]
return sheets