File size: 8,892 Bytes
3efc7f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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