ecode-cutting 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ecode_cutting/USAGE.md +673 -0
- ecode_cutting/__init__.py +18 -0
- ecode_cutting/_common.py +158 -0
- ecode_cutting/_linear_fast.py +231 -0
- ecode_cutting/compat.py +659 -0
- ecode_cutting/engine.py +558 -0
- ecode_cutting/exporters.py +362 -0
- ecode_cutting/linear.py +532 -0
- ecode_cutting/models.py +230 -0
- ecode_cutting/portfolio.py +234 -0
- ecode_cutting/py.typed +0 -0
- ecode_cutting/rectangular.py +357 -0
- ecode_cutting/serialization.py +234 -0
- ecode_cutting/validation.py +234 -0
- ecode_cutting-0.2.3.dist-info/METADATA +190 -0
- ecode_cutting-0.2.3.dist-info/RECORD +18 -0
- ecode_cutting-0.2.3.dist-info/WHEEL +5 -0
- ecode_cutting-0.2.3.dist-info/top_level.txt +1 -0
ecode_cutting/linear.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""一维型材排样:整数长度、真实斜切轮廓及按需组合原料。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from heapq import heappop, heappush
|
|
8
|
+
from math import ceil, hypot, radians, tan
|
|
9
|
+
from random import Random
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
from ._common import Runtime, matches, quantize
|
|
13
|
+
from .models import (Cut, Layout, Options, Part, Placement, Remnant, Result,
|
|
14
|
+
Stock, StockComponent, Unplaced, ValidationError)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _Bar:
|
|
19
|
+
components: tuple[tuple[int, int], ...]
|
|
20
|
+
width: int
|
|
21
|
+
material: int
|
|
22
|
+
waste: bool
|
|
23
|
+
# 每项保存输入件、实例、包围盒起点、两端偏移、镜像、反向。
|
|
24
|
+
items: list[tuple[int, int, int, int, int, bool, bool]] = field(default_factory=list)
|
|
25
|
+
end: int = 0
|
|
26
|
+
types: Counter = field(default_factory=Counter)
|
|
27
|
+
|
|
28
|
+
def copy(self):
|
|
29
|
+
return replace(self, items=self.items.copy(), types=self.types.copy())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _polygon(x: float, width: float, height: float, start: float, end: float):
|
|
33
|
+
"""斜端线裁剪包围矩形;短件端线相交时自然得到三角形。"""
|
|
34
|
+
points = [(x, 0.0), (x + width, 0.0), (x + width, height), (x, height)]
|
|
35
|
+
for offset, left in ((start, True), (end, False)):
|
|
36
|
+
def distance(p):
|
|
37
|
+
line = (x + max(offset, 0) - offset * p[1] / height if left else
|
|
38
|
+
x + width + min(offset, 0) - offset * p[1] / height)
|
|
39
|
+
return p[0] - line if left else line - p[0]
|
|
40
|
+
clipped = []
|
|
41
|
+
for a, b in zip(points, points[1:] + points[:1]):
|
|
42
|
+
da, db = distance(a), distance(b)
|
|
43
|
+
if da >= -1e-10:
|
|
44
|
+
clipped.append(a)
|
|
45
|
+
if (da < 0) != (db < 0):
|
|
46
|
+
t = da / (da - db)
|
|
47
|
+
clipped.append((a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])))
|
|
48
|
+
points = []
|
|
49
|
+
for point in clipped:
|
|
50
|
+
if not points or any(abs(a - b) > 1e-9 for a, b in zip(point, points[-1])):
|
|
51
|
+
points.append(point)
|
|
52
|
+
if len(points) > 1 and all(abs(a - b) < 1e-9 for a, b in zip(points[0], points[-1])):
|
|
53
|
+
points.pop()
|
|
54
|
+
return tuple(points)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def solve_linear(stocks: Sequence[Stock], parts: Sequence[Part], options: Options,
|
|
58
|
+
runtime: Runtime) -> Result:
|
|
59
|
+
scale = 10 ** options.max_decimal_point
|
|
60
|
+
q = lambda n: quantize(n, options.max_decimal_point)
|
|
61
|
+
widths = [q(p.width) for p in parts]
|
|
62
|
+
stock_widths = [q(s.width) for s in stocks]
|
|
63
|
+
left, right, kerf, minimum = map(q, (options.trim_left, options.trim_right,
|
|
64
|
+
options.saw_width, options.waste_size_min))
|
|
65
|
+
section = q(options.cross_section)
|
|
66
|
+
|
|
67
|
+
def bevel(angle):
|
|
68
|
+
if not angle or angle == 90:
|
|
69
|
+
return 0
|
|
70
|
+
value = section / tan(radians(angle))
|
|
71
|
+
# 原库斜端偏移取远离零的中点舍入。
|
|
72
|
+
return int(value + .5) if value >= 0 else -int(-value + .5)
|
|
73
|
+
|
|
74
|
+
offsets = [(bevel(p.angle_start), bevel(p.angle_end)) for p in parts]
|
|
75
|
+
keys = [(widths[i], *offsets[i]) for i in range(len(parts))]
|
|
76
|
+
demand = Counter()
|
|
77
|
+
for i, p in enumerate(parts):
|
|
78
|
+
demand[keys[i]] += p.count
|
|
79
|
+
requested = sum(p.count for p in parts)
|
|
80
|
+
instances = [(i, j) for i, p in enumerate(parts) for j in range(p.count)]
|
|
81
|
+
best: list[_Bar] = []
|
|
82
|
+
best_score = None
|
|
83
|
+
attempts = 0
|
|
84
|
+
# 仅直端、无拼接时计入每根的修边与锯缝;其它情形不猜测容量下界。
|
|
85
|
+
capacity = max(stock_widths, default=0) - left - right + kerf
|
|
86
|
+
lower_bound = ((sum((widths[i] + kerf) * p.count for i, p in enumerate(parts))
|
|
87
|
+
+ capacity - 1) // capacity
|
|
88
|
+
if capacity > 0 and not options.max_size_per_stock
|
|
89
|
+
and not any(a or b for a, b in offsets) else 0)
|
|
90
|
+
|
|
91
|
+
def count_bound_reached():
|
|
92
|
+
return best_score is not None and -best_score[0] == requested and best_score[1] == lower_bound
|
|
93
|
+
|
|
94
|
+
def axial_kerf(offset):
|
|
95
|
+
# 以最终整数轮廓的真实切线换算法向锯缝,避免精度量化后几何不一致。
|
|
96
|
+
return ceil(kerf * hypot(section, offset) / section - 1e-9) if section else kerf
|
|
97
|
+
|
|
98
|
+
def tail(bar):
|
|
99
|
+
if not bar.items or bar.end >= bar.width - right:
|
|
100
|
+
return bar.end, 0
|
|
101
|
+
x = min(bar.width - right, bar.end + axial_kerf(bar.items[-1][4]))
|
|
102
|
+
return x, bar.width - right - x
|
|
103
|
+
|
|
104
|
+
def valid_tail(bar):
|
|
105
|
+
remaining = tail(bar)[1]
|
|
106
|
+
return remaining == 0 or remaining >= minimum
|
|
107
|
+
|
|
108
|
+
def finish_bars(bars):
|
|
109
|
+
# 中间状态可暂时留下小余料;最终未能填满时撤回尾件,保留合法前缀。
|
|
110
|
+
finished = []
|
|
111
|
+
for original in bars:
|
|
112
|
+
bar = original.copy()
|
|
113
|
+
while bar.items and not valid_tail(bar):
|
|
114
|
+
i, *_ = bar.items.pop()
|
|
115
|
+
bar.types[keys[i]] -= 1
|
|
116
|
+
if not bar.types[keys[i]]:
|
|
117
|
+
del bar.types[keys[i]]
|
|
118
|
+
bar.end = max((item[2] + widths[item[0]] for item in bar.items), default=left)
|
|
119
|
+
if bar.items:
|
|
120
|
+
finished.append(bar)
|
|
121
|
+
return finished
|
|
122
|
+
|
|
123
|
+
def available(i, used):
|
|
124
|
+
return stocks[i].count == 0 or used[i] < stocks[i].count
|
|
125
|
+
|
|
126
|
+
def make_bar(components, used):
|
|
127
|
+
seen = used.copy()
|
|
128
|
+
refs = []
|
|
129
|
+
for i in components:
|
|
130
|
+
refs.append((i, seen[i]))
|
|
131
|
+
seen[i] += 1
|
|
132
|
+
materials = {stocks[i].material for i in components if stocks[i].material != -1}
|
|
133
|
+
return _Bar(tuple(refs), sum(stock_widths[i] for i in components),
|
|
134
|
+
next(iter(materials), -1), any(stocks[i].waste for i in components), end=left)
|
|
135
|
+
|
|
136
|
+
def combination(part_index, used):
|
|
137
|
+
"""最短覆盖组合;库存仅保存计数,绝不展开无限原料。"""
|
|
138
|
+
if not options.max_size_per_stock:
|
|
139
|
+
return None
|
|
140
|
+
required = widths[part_index] + left + right
|
|
141
|
+
final_kerf = min((axial_kerf(item[1]) for item in orientation_choices[part_index]), default=kerf)
|
|
142
|
+
candidates = [i for i, s in enumerate(stocks)
|
|
143
|
+
if available(i, used) and matches(s.material, parts[part_index].material)]
|
|
144
|
+
if not candidates:
|
|
145
|
+
return None
|
|
146
|
+
# 同长度原料可直接整除/向上取整,常见的无限库存不进入子集枚举。
|
|
147
|
+
fallback = None
|
|
148
|
+
groups = {}
|
|
149
|
+
for i in candidates:
|
|
150
|
+
group = (stock_widths[i], None if options.allow_combine_stock_regular_and_waste else stocks[i].waste)
|
|
151
|
+
groups.setdefault(group, []).append(i)
|
|
152
|
+
for (width, _), group in groups.items():
|
|
153
|
+
quantity = (required + width - 1) // width
|
|
154
|
+
remainder = quantity * width - required
|
|
155
|
+
if final_kerf < remainder < minimum + final_kerf:
|
|
156
|
+
quantity = (required + minimum + final_kerf + width - 1) // width
|
|
157
|
+
selected = []
|
|
158
|
+
for i in sorted(group, key=lambda j: not stocks[j].waste):
|
|
159
|
+
take = quantity - len(selected)
|
|
160
|
+
if stocks[i].count:
|
|
161
|
+
take = min(take, stocks[i].count - used[i])
|
|
162
|
+
selected.extend([i] * take)
|
|
163
|
+
if len(selected) == quantity:
|
|
164
|
+
bar = make_bar(selected, used)
|
|
165
|
+
if fallback is None or (bar.width, len(bar.components), not bar.waste) < (fallback.width, len(fallback.components), not fallback.waste):
|
|
166
|
+
fallback = bar
|
|
167
|
+
break
|
|
168
|
+
if fallback is not None and (fallback.width == required or options.max_size_per_stock == 1):
|
|
169
|
+
return fallback
|
|
170
|
+
if options.max_size_per_stock == 1:
|
|
171
|
+
return None
|
|
172
|
+
upper = required + minimum + final_kerf + max(stock_widths[i] for i in candidates)
|
|
173
|
+
if fallback is not None:
|
|
174
|
+
upper = min(upper, fallback.width)
|
|
175
|
+
queue = [(0, (), ())]
|
|
176
|
+
seen = set()
|
|
177
|
+
visits = 0
|
|
178
|
+
while queue:
|
|
179
|
+
length, components, lengths = heappop(queue)
|
|
180
|
+
visits += 1
|
|
181
|
+
if visits % 64 == 0 and runtime.stopped():
|
|
182
|
+
return fallback
|
|
183
|
+
remainder = length - required
|
|
184
|
+
if length >= required and (remainder <= final_kerf or remainder >= minimum + final_kerf):
|
|
185
|
+
return make_bar(components, used)
|
|
186
|
+
for i in candidates:
|
|
187
|
+
if components and i < components[-1]:
|
|
188
|
+
continue
|
|
189
|
+
if not options.allow_combine_stock_regular_and_waste and components and stocks[i].waste != stocks[components[0]].waste:
|
|
190
|
+
continue
|
|
191
|
+
if stocks[i].count and used[i] + components.count(i) >= stocks[i].count:
|
|
192
|
+
continue
|
|
193
|
+
new_lengths = tuple(sorted(set(lengths + (stock_widths[i],))))
|
|
194
|
+
if options.max_size_per_stock > 0 and len(new_lengths) > options.max_size_per_stock:
|
|
195
|
+
continue
|
|
196
|
+
new_length = length + stock_widths[i]
|
|
197
|
+
new_components = components + (i,)
|
|
198
|
+
state = (new_components, new_lengths)
|
|
199
|
+
if new_length <= upper and state not in seen:
|
|
200
|
+
seen.add(state)
|
|
201
|
+
heappush(queue, (new_length, new_components, new_lengths))
|
|
202
|
+
return fallback
|
|
203
|
+
|
|
204
|
+
def build_orientations(i):
|
|
205
|
+
a, b = offsets[i]
|
|
206
|
+
result = [(a, b, False, False)]
|
|
207
|
+
if options.linear_allow_rotate:
|
|
208
|
+
result.append((-a, -b, True, False))
|
|
209
|
+
if options.linear_allow_flipping:
|
|
210
|
+
result.append((-b, -a, False, True))
|
|
211
|
+
if options.linear_allow_rotate:
|
|
212
|
+
result.append((b, a, True, True))
|
|
213
|
+
# 相同斜率的两端可能完全吃掉短件;不能输出空轮廓冒充成品。
|
|
214
|
+
return [item for item in dict.fromkeys(result)
|
|
215
|
+
if max(widths[i] + min(item[1], 0) - max(item[0], 0),
|
|
216
|
+
widths[i] - max(item[1], 0) - max(-item[0], 0)) > 0]
|
|
217
|
+
|
|
218
|
+
orientation_choices = [build_orientations(i) for i in range(len(parts))]
|
|
219
|
+
|
|
220
|
+
def fit(bar, i, counts, *, check_remnant=False):
|
|
221
|
+
if not matches(bar.material, parts[i].material):
|
|
222
|
+
return None
|
|
223
|
+
if options.linear_max_size_per_stock and keys[i] not in bar.types and len(bar.types) >= options.linear_max_size_per_stock:
|
|
224
|
+
utilization = sum(key[0] * count for key, count in bar.types.items()) / max(1, bar.width - left - right)
|
|
225
|
+
if utilization > options.linear_max_size_per_stock_threshold or any(counts[k] < demand[k] for k in bar.types):
|
|
226
|
+
return None
|
|
227
|
+
best_fit = None
|
|
228
|
+
for a, b, mirrored, reversed_ in orientation_choices[i]:
|
|
229
|
+
overlap, gap = 0, 0
|
|
230
|
+
if bar.items:
|
|
231
|
+
previous = bar.items[-1][4]
|
|
232
|
+
if options.linear_exact_angle and previous != a:
|
|
233
|
+
continue
|
|
234
|
+
if previous * a > 0:
|
|
235
|
+
overlap = min(abs(previous), abs(a))
|
|
236
|
+
# 锯缝沿切线法向给定,换算为型材长度方向占用。
|
|
237
|
+
gap = max(axial_kerf(previous), axial_kerf(a))
|
|
238
|
+
x = max(left, bar.end + gap - overlap)
|
|
239
|
+
finish = max(bar.end, x + widths[i])
|
|
240
|
+
remainder = bar.width - right - finish
|
|
241
|
+
real_remainder = max(0, remainder - axial_kerf(b))
|
|
242
|
+
if remainder < 0 or (check_remnant and real_remainder != 0 and real_remainder < minimum):
|
|
243
|
+
continue
|
|
244
|
+
candidate = (finish, x, a, b, mirrored, reversed_)
|
|
245
|
+
if best_fit is None or candidate < best_fit:
|
|
246
|
+
best_fit = candidate
|
|
247
|
+
return best_fit
|
|
248
|
+
|
|
249
|
+
def append(bar, i, instance, fitted):
|
|
250
|
+
finish, x, a, b, mirrored, reversed_ = fitted
|
|
251
|
+
bar.items.append((i, instance, x, a, b, mirrored, reversed_))
|
|
252
|
+
bar.end = finish
|
|
253
|
+
bar.types[keys[i]] += 1
|
|
254
|
+
|
|
255
|
+
def new_bars(i, used):
|
|
256
|
+
candidates = [j for j, s in enumerate(stocks) if available(j, used)
|
|
257
|
+
and matches(s.material, parts[i].material)
|
|
258
|
+
and stock_widths[j] - left - right >= widths[i]]
|
|
259
|
+
candidates.sort(key=lambda j: (not stocks[j].waste,
|
|
260
|
+
-stock_widths[j] if options.use_large_stock_first else stock_widths[j], j))
|
|
261
|
+
result = [make_bar((j,), used) for j in candidates]
|
|
262
|
+
if not result:
|
|
263
|
+
joined = combination(i, used)
|
|
264
|
+
if joined is not None:
|
|
265
|
+
result.append(joined)
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
def score(bars):
|
|
269
|
+
placed = sum(len(b.items) for b in bars)
|
|
270
|
+
stock_count = sum(len(b.components) for b in bars)
|
|
271
|
+
regular = sum(not stocks[i].waste for b in bars for i, _ in b.components)
|
|
272
|
+
patterns = (len({(b.width, tuple(sorted(b.types.items()))) for b in bars})
|
|
273
|
+
if options.use_layout_minimization else 0)
|
|
274
|
+
cut_count = 0
|
|
275
|
+
for bar in bars:
|
|
276
|
+
count = 2 * len(bar.items) - sum(a[4] == b[3] for a, b in zip(bar.items, bar.items[1:]))
|
|
277
|
+
first, last = bar.items[0], bar.items[-1]
|
|
278
|
+
count -= not first[3] and first[2] in (0, left)
|
|
279
|
+
count -= not last[4] and last[2] + widths[last[0]] in (bar.width, bar.width - right)
|
|
280
|
+
cut_count += count + bool(left) + bool(right)
|
|
281
|
+
return (-placed, stock_count, regular,
|
|
282
|
+
patterns, sum(b.width for b in bars), cut_count)
|
|
283
|
+
|
|
284
|
+
def remember(bars):
|
|
285
|
+
nonlocal best, best_score
|
|
286
|
+
candidate = score(bars)
|
|
287
|
+
if best_score is None or candidate < best_score:
|
|
288
|
+
best_score = candidate
|
|
289
|
+
best = [b.copy() for b in bars]
|
|
290
|
+
|
|
291
|
+
def reduce_bars():
|
|
292
|
+
"""撤去一根原料,在小组内重新分配;失败时完整保留原解。"""
|
|
293
|
+
# 直端件的占用与顺序无关;斜端仍使用原来的轮廓搜索。
|
|
294
|
+
if any(a or b for a, b in offsets) or not best or -best_score[0] != requested:
|
|
295
|
+
return
|
|
296
|
+
nodes = 0
|
|
297
|
+
changed = True
|
|
298
|
+
while changed and not count_bound_reached() and not runtime.stopped(requested, attempts):
|
|
299
|
+
changed = False
|
|
300
|
+
for removed in sorted(range(len(best)), key=lambda j: best[j].end):
|
|
301
|
+
source = best[removed]
|
|
302
|
+
group = [j for j, b in enumerate(best) if b.material == source.material]
|
|
303
|
+
items = [(item[0], item[1]) for j in group for item in best[j].items]
|
|
304
|
+
# ponytail: 大组不做指数搜索;保持5000件等批量订单的原有启发式路径。
|
|
305
|
+
if len(group) < 2 or len(group) > 8 or len(items) > 64:
|
|
306
|
+
continue
|
|
307
|
+
targets = [replace(best[j], items=[], types=Counter(), end=left)
|
|
308
|
+
for j in group if j != removed]
|
|
309
|
+
needed = sum(widths[i] + kerf for i, _ in items)
|
|
310
|
+
if needed > sum(b.width - left - right + kerf for b in targets):
|
|
311
|
+
continue
|
|
312
|
+
# 升序是最终每根原料的排入顺序,不能用降序偷换用户选项。
|
|
313
|
+
items.sort(key=lambda item: widths[item[0]], reverse=not options.linear_sort_ascending)
|
|
314
|
+
counts = Counter()
|
|
315
|
+
for j, b in enumerate(best):
|
|
316
|
+
if j not in group:
|
|
317
|
+
counts.update(b.types)
|
|
318
|
+
seen = set()
|
|
319
|
+
local_nodes = 0
|
|
320
|
+
|
|
321
|
+
def assign(position):
|
|
322
|
+
nonlocal nodes, local_nodes
|
|
323
|
+
nodes += 1
|
|
324
|
+
local_nodes += 1
|
|
325
|
+
if local_nodes > 20000 or nodes > 100000:
|
|
326
|
+
return False
|
|
327
|
+
if nodes % 64 == 0 and runtime.stopped(requested, attempts):
|
|
328
|
+
return False
|
|
329
|
+
if position == len(items):
|
|
330
|
+
return all(b.items and valid_tail(b) for b in targets)
|
|
331
|
+
state = (position, tuple((b.end, tuple(sorted(b.types.items()))) for b in targets))
|
|
332
|
+
if state in seen:
|
|
333
|
+
return False
|
|
334
|
+
seen.add(state)
|
|
335
|
+
i, instance = items[position]
|
|
336
|
+
symmetric = set()
|
|
337
|
+
for j in sorted(range(len(targets)), key=lambda j: targets[j].width - targets[j].end):
|
|
338
|
+
bar = targets[j]
|
|
339
|
+
signature = (bar.width, bar.waste, bar.end, tuple(sorted(bar.types.items())))
|
|
340
|
+
if signature in symmetric:
|
|
341
|
+
continue
|
|
342
|
+
symmetric.add(signature)
|
|
343
|
+
fitted = fit(bar, i, counts)
|
|
344
|
+
if fitted is None:
|
|
345
|
+
continue
|
|
346
|
+
saved = bar.copy()
|
|
347
|
+
append(bar, i, instance, fitted)
|
|
348
|
+
counts[keys[i]] += 1
|
|
349
|
+
if assign(position + 1):
|
|
350
|
+
return True
|
|
351
|
+
counts[keys[i]] -= 1
|
|
352
|
+
targets[j] = saved
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
if assign(0):
|
|
356
|
+
remember([b for j, b in enumerate(best) if j not in group] + targets)
|
|
357
|
+
changed = True
|
|
358
|
+
break
|
|
359
|
+
if nodes > 100000 or runtime.stopped(requested, attempts):
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
if runtime.stopped():
|
|
363
|
+
return Result(unplaced=tuple(Unplaced(i, p.count, p.id) for i, p in enumerate(parts) if p.count),
|
|
364
|
+
elapsed=runtime.elapsed, linear=True, cancelled=runtime.cancelled, timed_out=runtime.timed_out)
|
|
365
|
+
if options.complete_mode:
|
|
366
|
+
for i, p in enumerate(parts):
|
|
367
|
+
if p.count and not any(fit(b, i, Counter(), check_remnant=False) for b in new_bars(i, Counter())):
|
|
368
|
+
if runtime.stopped():
|
|
369
|
+
break
|
|
370
|
+
raise ValidationError(f"parts[{i}] 无法放入任何可用原料(含修边、余料和组合约束)")
|
|
371
|
+
|
|
372
|
+
rng = Random(options.seed)
|
|
373
|
+
# 固定升序的重复尝试完全相同,把剩余预算留给有目标的局部重排。
|
|
374
|
+
for attempt in range(1 if options.linear_sort_ascending else options.max_attempts):
|
|
375
|
+
if runtime.stopped(-best_score[0] if best_score else 0, attempt):
|
|
376
|
+
break
|
|
377
|
+
attempts += 1
|
|
378
|
+
order = instances.copy()
|
|
379
|
+
if options.linear_sort_ascending:
|
|
380
|
+
order.sort(key=lambda item: widths[item[0]])
|
|
381
|
+
elif attempt == 0:
|
|
382
|
+
order.sort(key=lambda item: widths[item[0]], reverse=True)
|
|
383
|
+
elif attempt == 1:
|
|
384
|
+
order.sort(key=lambda item: widths[item[0]])
|
|
385
|
+
else:
|
|
386
|
+
rng.shuffle(order)
|
|
387
|
+
bars, used, counts = [], Counter(), Counter()
|
|
388
|
+
for position, (i, instance) in enumerate(order):
|
|
389
|
+
if position % 32 == 0 and runtime.stopped(position, attempt + 1):
|
|
390
|
+
break
|
|
391
|
+
candidates = []
|
|
392
|
+
for j, bar in enumerate(bars):
|
|
393
|
+
fitted = fit(bar, i, counts)
|
|
394
|
+
if fitted is not None:
|
|
395
|
+
candidates.append((not bar.waste, bar.width - right - fitted[0], j, fitted))
|
|
396
|
+
if candidates:
|
|
397
|
+
_, _, j, fitted = min(candidates)
|
|
398
|
+
append(bars[j], i, instance, fitted)
|
|
399
|
+
else:
|
|
400
|
+
for bar in new_bars(i, used):
|
|
401
|
+
fitted = fit(bar, i, counts)
|
|
402
|
+
if fitted is not None:
|
|
403
|
+
append(bar, i, instance, fitted)
|
|
404
|
+
bars.append(bar)
|
|
405
|
+
used.update(j for j, _ in bar.components)
|
|
406
|
+
break
|
|
407
|
+
else:
|
|
408
|
+
continue
|
|
409
|
+
counts[keys[i]] += 1
|
|
410
|
+
remember(finish_bars(bars))
|
|
411
|
+
|
|
412
|
+
# ponytail: 指数搜索仅用于不超过 16 件的小实例;大批量沿用多次装箱启发式。
|
|
413
|
+
if len(instances) <= 16 and not options.linear_sort_ascending and not options.use_large_stock_first and not runtime.stopped():
|
|
414
|
+
order = sorted(instances, key=lambda item: widths[item[0]], reverse=True)
|
|
415
|
+
nodes = 0
|
|
416
|
+
bars, used, counts = [], Counter(), Counter()
|
|
417
|
+
|
|
418
|
+
def search(position, placed):
|
|
419
|
+
nonlocal nodes
|
|
420
|
+
nodes += 1
|
|
421
|
+
if nodes > 100000 or (nodes % 128 == 0 and runtime.stopped(placed, attempts)):
|
|
422
|
+
return
|
|
423
|
+
if best_score and placed + len(order) - position < -best_score[0]:
|
|
424
|
+
return
|
|
425
|
+
if position == len(order):
|
|
426
|
+
remember(bars)
|
|
427
|
+
return
|
|
428
|
+
i, instance = order[position]
|
|
429
|
+
visited = set()
|
|
430
|
+
for j, bar in enumerate(bars):
|
|
431
|
+
state = (bar.width, bar.material, bar.end, tuple(bar.types.items()), bar.items[-1][4])
|
|
432
|
+
if state in visited:
|
|
433
|
+
continue
|
|
434
|
+
visited.add(state)
|
|
435
|
+
fitted = fit(bar, i, counts, check_remnant=False)
|
|
436
|
+
if fitted is None:
|
|
437
|
+
continue
|
|
438
|
+
saved = bar.copy()
|
|
439
|
+
append(bar, i, instance, fitted)
|
|
440
|
+
counts[keys[i]] += 1
|
|
441
|
+
search(position + 1, placed + 1)
|
|
442
|
+
counts[keys[i]] -= 1
|
|
443
|
+
bars[j] = saved
|
|
444
|
+
for bar in new_bars(i, used):
|
|
445
|
+
fitted = fit(bar, i, counts, check_remnant=False)
|
|
446
|
+
if fitted is None:
|
|
447
|
+
continue
|
|
448
|
+
append(bar, i, instance, fitted)
|
|
449
|
+
bars.append(bar)
|
|
450
|
+
used.update(j for j, _ in bar.components)
|
|
451
|
+
counts[keys[i]] += 1
|
|
452
|
+
search(position + 1, placed + 1)
|
|
453
|
+
counts[keys[i]] -= 1
|
|
454
|
+
used.subtract(j for j, _ in bar.components)
|
|
455
|
+
bars.pop()
|
|
456
|
+
search(position + 1, placed)
|
|
457
|
+
|
|
458
|
+
original_remember = remember
|
|
459
|
+
def remember(bars):
|
|
460
|
+
if all(valid_tail(b) for b in bars):
|
|
461
|
+
original_remember(bars)
|
|
462
|
+
search(0, 0)
|
|
463
|
+
|
|
464
|
+
reduce_bars()
|
|
465
|
+
|
|
466
|
+
layouts = []
|
|
467
|
+
placed_counts = Counter()
|
|
468
|
+
stock_instances = Counter()
|
|
469
|
+
for bar in best:
|
|
470
|
+
# 尾段回退可能释放整根原料;最终实例重新连续编号,不保留试排空洞。
|
|
471
|
+
actual_components = []
|
|
472
|
+
for component_i, _ in bar.components:
|
|
473
|
+
actual_components.append((component_i, stock_instances[component_i]))
|
|
474
|
+
stock_instances[component_i] += 1
|
|
475
|
+
stock_i, stock_instance = actual_components[0]
|
|
476
|
+
s = stocks[stock_i]
|
|
477
|
+
height = options.cross_section or s.height
|
|
478
|
+
placements, cut_records = [], []
|
|
479
|
+
previous_end = None
|
|
480
|
+
for local_index, (i, instance, x, a, b, mirrored, reversed_) in enumerate(bar.items):
|
|
481
|
+
p = parts[i]
|
|
482
|
+
x, a, b = x / scale, a / scale, b / scale
|
|
483
|
+
width = widths[i] / scale
|
|
484
|
+
polygon = _polygon(x, width, height, a, b) if a or b else ()
|
|
485
|
+
start_angle, end_angle = (p.angle_end, p.angle_start) if reversed_ else (p.angle_start, p.angle_end)
|
|
486
|
+
if reversed_ != mirrored:
|
|
487
|
+
start_angle = 180 - start_angle if start_angle else 0
|
|
488
|
+
end_angle = 180 - end_angle if end_angle else 0
|
|
489
|
+
placements.append(Placement(i, placed_counts[i], width, height, x, id=p.id, material=p.material,
|
|
490
|
+
rotated=mirrored, flipped=reversed_, angle_start=start_angle,
|
|
491
|
+
angle_end=end_angle, polygon=polygon))
|
|
492
|
+
placed_counts[i] += 1
|
|
493
|
+
for is_start, top, bottom, angle in ((True, x + max(a, 0), x + max(-a, 0), start_angle),
|
|
494
|
+
(False, x + width + min(b, 0), x + width - max(b, 0), end_angle)):
|
|
495
|
+
if abs(top) < 1e-9 and abs(bottom) < 1e-9 or abs(top - bar.width / scale) < 1e-9 and abs(bottom - bar.width / scale) < 1e-9:
|
|
496
|
+
continue
|
|
497
|
+
if is_start and local_index == 0 and not a and abs(top - left / scale) < 1e-9 and left:
|
|
498
|
+
continue
|
|
499
|
+
if not is_start and not b and abs(top - (bar.width - right) / scale) < 1e-9 and right:
|
|
500
|
+
continue
|
|
501
|
+
offset = a if is_start else b
|
|
502
|
+
allowance = kerf / scale * hypot(height, offset) / (2 * height)
|
|
503
|
+
shift = -allowance if is_start else allowance
|
|
504
|
+
cut = Cut(top + shift, 0, bottom + shift, height, angle=angle or 90,
|
|
505
|
+
part_indices=(local_index,))
|
|
506
|
+
if (is_start and previous_end is not None
|
|
507
|
+
and abs(cut_records[previous_end].x1 - cut.x1) <= 1e-9
|
|
508
|
+
and abs(cut_records[previous_end].x2 - cut.x2) <= 1e-9):
|
|
509
|
+
# 相同斜端的一次共切,中心位于两条成品边之间,合并关联的局部零件索引。
|
|
510
|
+
old = cut_records[previous_end]
|
|
511
|
+
cut_records[previous_end] = replace(old, x1=(old.x1 + cut.x1) / 2,
|
|
512
|
+
x2=(old.x2 + cut.x2) / 2,
|
|
513
|
+
part_indices=old.part_indices + (local_index,))
|
|
514
|
+
else:
|
|
515
|
+
cut_records.append(cut)
|
|
516
|
+
if not is_start:
|
|
517
|
+
previous_end = len(cut_records) - 1
|
|
518
|
+
remnants = ()
|
|
519
|
+
remnant_x, remnant_width = tail(bar)
|
|
520
|
+
if remnant_width > 0 and remnant_width >= minimum:
|
|
521
|
+
remnants = (Remnant(remnant_width / scale, height, remnant_x / scale, material=bar.material),)
|
|
522
|
+
trim_cuts = tuple(Cut(x / scale, 0, x / scale, height)
|
|
523
|
+
for x, trim in ((left - kerf / 2, left),
|
|
524
|
+
(bar.width - right + kerf / 2, right)) if trim)
|
|
525
|
+
components = tuple(StockComponent(i, instance, stock_widths[i] / scale, stocks[i].id,
|
|
526
|
+
stocks[i].waste, stocks[i].material) for i, instance in actual_components)
|
|
527
|
+
layouts.append(Layout(stock_i, stock_instance, bar.width / scale, height,
|
|
528
|
+
";".join(stocks[i].id for i, _ in bar.components), bar.material,
|
|
529
|
+
bar.waste, tuple(placements), remnants, tuple(cut_records), trim_cuts, components))
|
|
530
|
+
return Result(tuple(layouts), tuple(Unplaced(i, p.count - placed_counts[i], p.id)
|
|
531
|
+
for i, p in enumerate(parts) if p.count > placed_counts[i]),
|
|
532
|
+
runtime.elapsed, True, runtime.cancelled, runtime.timed_out, attempts)
|