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.
@@ -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)