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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""整数坐标的断头排料:每个可用区域始终是一棵二叉切割树的叶子。
|
|
2
|
+
|
|
3
|
+
采用有界多策略启发式,不承诺 NP 困难问题的全局最优。内部切线表示锯缝
|
|
4
|
+
起始边,公开 Cut 输出刀具中心线,树的前序遍历即实际加工顺序。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from random import Random
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from ._common import Runtime, matches, quantize
|
|
14
|
+
from .models import Cut, Layout, Options, Part, Placement, Remnant, Result, Stock, Unplaced, ValidationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class _Region:
|
|
19
|
+
x: int
|
|
20
|
+
y: int
|
|
21
|
+
w: int
|
|
22
|
+
h: int
|
|
23
|
+
direction: str = ""
|
|
24
|
+
level: int = 0
|
|
25
|
+
split: str = ""
|
|
26
|
+
position: int = 0
|
|
27
|
+
first: _Region | None = None
|
|
28
|
+
second: _Region | None = None
|
|
29
|
+
part: tuple[int, int, int, int, bool] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _stages(region: _Region, directions: list[str]) -> list[int]:
|
|
33
|
+
direction, level = region.direction, region.level
|
|
34
|
+
levels = []
|
|
35
|
+
for current in directions:
|
|
36
|
+
level += current != direction
|
|
37
|
+
direction = current
|
|
38
|
+
levels.append(level)
|
|
39
|
+
return levels
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _splits(region: _Region, w: int, h: int, first: str, saw: int,
|
|
43
|
+
options: Options) -> tuple[list[str], list[int]] | None:
|
|
44
|
+
if w > region.w or h > region.h:
|
|
45
|
+
return None
|
|
46
|
+
# 不足一道锯缝的尾边不能假装成为零件,也不能吞进零件尺寸。
|
|
47
|
+
dw, dh = region.w - w, region.h - h
|
|
48
|
+
if (dw and dw < saw) or (dh and dh < saw):
|
|
49
|
+
return None
|
|
50
|
+
directions = [d for d in (first, "h" if first == "v" else "v")
|
|
51
|
+
if (dw if d == "v" else dh)]
|
|
52
|
+
# 指定方向约束第一道实际分割;已经恰好等于原料的零件无需切割。
|
|
53
|
+
if not region.direction and directions and options.cut_direction != "auto":
|
|
54
|
+
required = "v" if options.cut_direction == "vertical" else "h"
|
|
55
|
+
if directions[0] != required:
|
|
56
|
+
return None
|
|
57
|
+
levels = _stages(region, directions)
|
|
58
|
+
if levels and max(levels) > options.max_cut_level:
|
|
59
|
+
return None
|
|
60
|
+
return directions, levels
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _insert(region: _Region, index: int, instance: int, w: int, h: int,
|
|
64
|
+
rotated: bool, directions: list[str], levels: list[int], saw: int) -> list[_Region]:
|
|
65
|
+
free = []
|
|
66
|
+
current = region
|
|
67
|
+
for direction, level in zip(directions, levels):
|
|
68
|
+
current.split = direction
|
|
69
|
+
current.level = level
|
|
70
|
+
current.position = current.x + w if direction == "v" else current.y + h
|
|
71
|
+
if direction == "v":
|
|
72
|
+
current.first = _Region(current.x, current.y, w, current.h, direction, level)
|
|
73
|
+
current.second = _Region(current.x + w + saw, current.y,
|
|
74
|
+
current.w - w - saw, current.h, direction, level)
|
|
75
|
+
else:
|
|
76
|
+
current.first = _Region(current.x, current.y, current.w, h, direction, level)
|
|
77
|
+
current.second = _Region(current.x, current.y + h + saw,
|
|
78
|
+
current.w, current.h - h - saw, direction, level)
|
|
79
|
+
if current.second.w > 0 and current.second.h > 0:
|
|
80
|
+
free.append(current.second)
|
|
81
|
+
current = current.first
|
|
82
|
+
current.part = (index, instance, w, h, rotated)
|
|
83
|
+
return free
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pack(stock: Stock, dimensions: tuple[int, int], counts: list[int], parts: Sequence[Part],
|
|
87
|
+
sizes: list[tuple[int, int]], order: list[int], options: Options, runtime: Runtime,
|
|
88
|
+
attempt: int, placed_before: int) -> tuple[_Region, list[int]]:
|
|
89
|
+
left, top, right, bottom = (quantize(getattr(options, "trim_" + edge), options.max_decimal_point)
|
|
90
|
+
for edge in ("left", "top", "right", "bottom"))
|
|
91
|
+
root = _Region(left, top, dimensions[0] - left - right, dimensions[1] - top - bottom)
|
|
92
|
+
used = [0] * len(parts)
|
|
93
|
+
if root.w <= 0 or root.h <= 0:
|
|
94
|
+
return root, used
|
|
95
|
+
free = [root]
|
|
96
|
+
saw = quantize(options.saw_width, options.max_decimal_point)
|
|
97
|
+
minimum = quantize(options.waste_size_min, options.max_decimal_point)
|
|
98
|
+
placed = 0
|
|
99
|
+
history = []
|
|
100
|
+
last_valid = 0
|
|
101
|
+
used_right, used_bottom = root.x, root.y
|
|
102
|
+
|
|
103
|
+
def valid_remnants():
|
|
104
|
+
for r in free:
|
|
105
|
+
rw, rh = r.w, r.h
|
|
106
|
+
if options.roll_mode:
|
|
107
|
+
if options.cut_direction == "vertical":
|
|
108
|
+
rw = min(r.x + rw, used_right) - r.x
|
|
109
|
+
else:
|
|
110
|
+
rh = min(r.y + rh, used_bottom) - r.y
|
|
111
|
+
if rw > 0 and rh > 0 and ((rw < minimum and rw != root.w) or
|
|
112
|
+
(rh < minimum and rh != root.h)):
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
while free:
|
|
116
|
+
if runtime.stopped(placed_before + placed, attempt):
|
|
117
|
+
break
|
|
118
|
+
selected = None
|
|
119
|
+
for index in order:
|
|
120
|
+
part = parts[index]
|
|
121
|
+
if counts[index] <= used[index] or not matches(stock.material, part.material):
|
|
122
|
+
continue
|
|
123
|
+
w0, h0 = sizes[index]
|
|
124
|
+
orientations = [(w0, h0, False)]
|
|
125
|
+
if part.rotatable and w0 != h0:
|
|
126
|
+
orientations.append((h0, w0, True))
|
|
127
|
+
best = None
|
|
128
|
+
for free_index, region in enumerate(free):
|
|
129
|
+
for w, h, rotated in orientations:
|
|
130
|
+
for first in ("v", "h"):
|
|
131
|
+
valid = _splits(region, w, h, first, saw, options)
|
|
132
|
+
if valid is None:
|
|
133
|
+
continue
|
|
134
|
+
directions, levels = valid
|
|
135
|
+
# 优先贴合短边,避免把可重复利用的整条原料切成碎片。
|
|
136
|
+
dw, dh = region.w - w, region.h - h
|
|
137
|
+
repeat_capacity = ((region.w + saw) // (w + saw)) * ((region.h + saw) // (h + saw))
|
|
138
|
+
score = (region.w * region.h - w * h, -repeat_capacity, min(dw, dh), max(dw, dh), len(directions), rotated,
|
|
139
|
+
0 if first == ("v" if attempt % 2 == 0 else "h") else 1)
|
|
140
|
+
if options.minimize_sheet_rotation:
|
|
141
|
+
score = (max(levels, default=region.level),) + score
|
|
142
|
+
candidate = (score, free_index, w, h, rotated, directions, levels)
|
|
143
|
+
if best is None or score < best[0]:
|
|
144
|
+
best = candidate
|
|
145
|
+
if best is not None:
|
|
146
|
+
selected = (index, best)
|
|
147
|
+
break
|
|
148
|
+
if selected is None:
|
|
149
|
+
break
|
|
150
|
+
index, (_, free_index, w, h, rotated, directions, levels) = selected
|
|
151
|
+
region = free.pop(free_index)
|
|
152
|
+
if minimum:
|
|
153
|
+
history.append((region, index, region.level))
|
|
154
|
+
free.extend(_insert(region, index, used[index], w, h, rotated, directions, levels, saw))
|
|
155
|
+
used[index] += 1
|
|
156
|
+
placed += 1
|
|
157
|
+
used_right, used_bottom = max(used_right, region.x + w), max(used_bottom, region.y + h)
|
|
158
|
+
if minimum and valid_remnants():
|
|
159
|
+
last_valid = len(history)
|
|
160
|
+
# 最小余料约束作用于最终余料,允许中间的小区域被后续零件恰好填满。
|
|
161
|
+
# 回退最后一段尚未形成合法余料的放置,保留此前已取得的有效部分布局。
|
|
162
|
+
for region, index, old_level in reversed(history[last_valid:]):
|
|
163
|
+
region.split, region.position, region.level = "", 0, old_level
|
|
164
|
+
region.first = region.second = None
|
|
165
|
+
region.part = None
|
|
166
|
+
used[index] -= 1
|
|
167
|
+
return root, used
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _layout(stock_index: int, instance: int, stock: Stock, root: _Region, offsets: list[int],
|
|
171
|
+
parts: Sequence[Part], options: Options, scale: int) -> Layout:
|
|
172
|
+
nodes = [root]
|
|
173
|
+
occupied = []
|
|
174
|
+
while nodes:
|
|
175
|
+
node = nodes.pop()
|
|
176
|
+
if node.part is not None:
|
|
177
|
+
occupied.append(node)
|
|
178
|
+
if node.first is not None:
|
|
179
|
+
nodes.extend((node.second, node.first))
|
|
180
|
+
left, top, right, bottom = (quantize(getattr(options, "trim_" + edge), options.max_decimal_point)
|
|
181
|
+
for edge in ("left", "top", "right", "bottom"))
|
|
182
|
+
width, height = quantize(stock.width, options.max_decimal_point), quantize(stock.height, options.max_decimal_point)
|
|
183
|
+
original_width, original_height = width, height
|
|
184
|
+
saw = quantize(options.saw_width, options.max_decimal_point)
|
|
185
|
+
half_saw = saw / 2
|
|
186
|
+
if options.roll_mode:
|
|
187
|
+
if options.cut_direction == "vertical":
|
|
188
|
+
width = max(n.x + n.part[2] for n in occupied) + right
|
|
189
|
+
if width + saw > original_width:
|
|
190
|
+
width = original_width
|
|
191
|
+
else:
|
|
192
|
+
height = max(n.y + n.part[3] for n in occupied) + bottom
|
|
193
|
+
if height + saw > original_height:
|
|
194
|
+
height = original_height
|
|
195
|
+
usable_right, usable_bottom = width - right, height - bottom
|
|
196
|
+
placements, remnants, cuts = [], [], []
|
|
197
|
+
visited, cut_nodes, part_nodes = [], [], {}
|
|
198
|
+
nodes = [root]
|
|
199
|
+
while nodes:
|
|
200
|
+
node = nodes.pop()
|
|
201
|
+
w, h = min(node.x + node.w, usable_right) - node.x, min(node.y + node.h, usable_bottom) - node.y
|
|
202
|
+
if w <= 0 or h <= 0:
|
|
203
|
+
continue
|
|
204
|
+
visited.append(node)
|
|
205
|
+
if node.part is not None:
|
|
206
|
+
index, item, pw, ph, rotated = node.part
|
|
207
|
+
part = parts[index]
|
|
208
|
+
part_nodes[id(node)] = (len(placements),)
|
|
209
|
+
placements.append(Placement(index, offsets[index] + item, pw / scale, ph / scale,
|
|
210
|
+
node.x / scale, node.y / scale, part.id, part.material, rotated))
|
|
211
|
+
elif node.split:
|
|
212
|
+
# 卷材末端缩短以后,与新边缘重合的分割已经成为原料边,不再输出空刀。
|
|
213
|
+
if node.split == "v" and node.position < usable_right:
|
|
214
|
+
cuts.append(Cut((node.position + half_saw) / scale, node.y / scale, (node.position + half_saw) / scale,
|
|
215
|
+
(node.y + h) / scale, node.level, 90.0))
|
|
216
|
+
cut_nodes.append(node)
|
|
217
|
+
elif node.split == "h" and node.position < usable_bottom:
|
|
218
|
+
cuts.append(Cut(node.x / scale, (node.position + half_saw) / scale, (node.x + w) / scale,
|
|
219
|
+
(node.position + half_saw) / scale, node.level, 0.0))
|
|
220
|
+
cut_nodes.append(node)
|
|
221
|
+
nodes.extend((node.second, node.first))
|
|
222
|
+
else:
|
|
223
|
+
remnants.append(Remnant(w / scale, h / scale, node.x / scale, node.y / scale, stock.material))
|
|
224
|
+
for node in reversed(visited):
|
|
225
|
+
if node.first is not None:
|
|
226
|
+
part_nodes[id(node)] = part_nodes.get(id(node.first), ()) + part_nodes.get(id(node.second), ())
|
|
227
|
+
cuts = [replace(cut, part_indices=part_nodes.get(id(node), ())) for cut, node in zip(cuts, cut_nodes)]
|
|
228
|
+
# 卷材先切断实际用长。刀具中心落在保留板外半道锯缝处,锯缝由原卷尾段承担。
|
|
229
|
+
trim, roll_remnants = [], []
|
|
230
|
+
all_parts = tuple(range(len(placements)))
|
|
231
|
+
if width < original_width:
|
|
232
|
+
trim.append(Cut((width + half_saw) / scale, 0, (width + half_saw) / scale,
|
|
233
|
+
height / scale, part_indices=all_parts))
|
|
234
|
+
if width + saw < original_width:
|
|
235
|
+
roll_remnants.append(Remnant((original_width - width - saw) / scale,
|
|
236
|
+
original_height / scale, (width + saw) / scale,
|
|
237
|
+
0, stock.material))
|
|
238
|
+
if height < original_height:
|
|
239
|
+
trim.append(Cut(0, (height + half_saw) / scale, width / scale,
|
|
240
|
+
(height + half_saw) / scale, angle=0, part_indices=all_parts))
|
|
241
|
+
if height + saw < original_height:
|
|
242
|
+
roll_remnants.append(Remnant(original_width / scale,
|
|
243
|
+
(original_height - height - saw) / scale,
|
|
244
|
+
0, (height + saw) / scale, stock.material))
|
|
245
|
+
# 修边沿丢弃的一侧吃刀,中心线不会侵入可用板;首向跟随主刀方向。
|
|
246
|
+
vertical_first = root.split == "v" or (not root.split and options.cut_direction != "horizontal")
|
|
247
|
+
for vertical in (vertical_first, not vertical_first):
|
|
248
|
+
if vertical:
|
|
249
|
+
y1, y2 = (0, height) if vertical_first else (top, height - bottom)
|
|
250
|
+
for position, margin in ((left - half_saw, left), (width - right + half_saw, right)):
|
|
251
|
+
if margin:
|
|
252
|
+
trim.append(Cut(position / scale, y1 / scale, position / scale, y2 / scale,
|
|
253
|
+
part_indices=all_parts))
|
|
254
|
+
else:
|
|
255
|
+
x1, x2 = (left, width - right) if vertical_first else (0, width)
|
|
256
|
+
for position, margin in ((top - half_saw, top), (height - bottom + half_saw, bottom)):
|
|
257
|
+
if margin:
|
|
258
|
+
trim.append(Cut(x1 / scale, position / scale, x2 / scale, position / scale,
|
|
259
|
+
angle=0, part_indices=all_parts))
|
|
260
|
+
return Layout(stock_index, instance, width / scale, height / scale, stock.id, stock.material,
|
|
261
|
+
stock.waste, tuple(placements), tuple(remnants), tuple(cuts), tuple(trim),
|
|
262
|
+
roll_remnants=tuple(roll_remnants))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _signature(layout: Layout) -> tuple:
|
|
266
|
+
return (layout.input_index, layout.width, layout.height,
|
|
267
|
+
tuple(sorted((p.input_index, p.x, p.y, p.width, p.height) for p in layout.parts)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def solve_rectangular(stocks: Sequence[Stock], parts: Sequence[Part], options: Options, runtime: Runtime) -> Result:
|
|
271
|
+
"""计算矩形断头排料,库存为零表示不限量;所有尝试共享同一个时间预算。"""
|
|
272
|
+
scale = 10 ** options.max_decimal_point
|
|
273
|
+
sizes = [(quantize(p.width, options.max_decimal_point), quantize(p.height, options.max_decimal_point)) for p in parts]
|
|
274
|
+
stock_sizes = [(quantize(s.width, options.max_decimal_point), quantize(s.height, options.max_decimal_point)) for s in stocks]
|
|
275
|
+
requested = sum(p.count for p in parts)
|
|
276
|
+
if runtime.stopped():
|
|
277
|
+
return Result(unplaced=tuple(Unplaced(i, p.count, p.id) for i, p in enumerate(parts) if p.count),
|
|
278
|
+
elapsed=runtime.elapsed, cancelled=runtime.cancelled, timed_out=runtime.timed_out)
|
|
279
|
+
# CompleteMode 的前置语义与旧实现一致;库存数量不足仍以 unplaced 返回。
|
|
280
|
+
if options.complete_mode:
|
|
281
|
+
for index, part in enumerate(parts):
|
|
282
|
+
if runtime.stopped():
|
|
283
|
+
break
|
|
284
|
+
if not part.count:
|
|
285
|
+
continue
|
|
286
|
+
possible = False
|
|
287
|
+
for stock, (sw, sh) in zip(stocks, stock_sizes):
|
|
288
|
+
region = _Region(0, 0, sw - quantize(options.trim_left, options.max_decimal_point)
|
|
289
|
+
- quantize(options.trim_right, options.max_decimal_point),
|
|
290
|
+
sh - quantize(options.trim_top, options.max_decimal_point)
|
|
291
|
+
- quantize(options.trim_bottom, options.max_decimal_point))
|
|
292
|
+
if not matches(stock.material, part.material):
|
|
293
|
+
continue
|
|
294
|
+
w, h = sizes[index]
|
|
295
|
+
for pw, ph in ((w, h), (h, w)) if part.rotatable else ((w, h),):
|
|
296
|
+
if any(_splits(region, pw, ph, first, quantize(options.saw_width, options.max_decimal_point), options) is not None
|
|
297
|
+
for first in ("v", "h")):
|
|
298
|
+
possible = True
|
|
299
|
+
break
|
|
300
|
+
if possible:
|
|
301
|
+
break
|
|
302
|
+
if not possible:
|
|
303
|
+
raise ValidationError(f"零件 {index} 无法在材料、修边、锯缝及切割层级约束下放入任何原料")
|
|
304
|
+
best_layouts = []
|
|
305
|
+
best_unplaced = [p.count for p in parts]
|
|
306
|
+
best_score = None
|
|
307
|
+
attempts = 0
|
|
308
|
+
random = Random(options.seed)
|
|
309
|
+
for attempt in range(options.max_attempts):
|
|
310
|
+
if runtime.stopped(requested - sum(best_unplaced), attempt):
|
|
311
|
+
break
|
|
312
|
+
attempts += 1
|
|
313
|
+
remaining = [p.count for p in parts]
|
|
314
|
+
consumed = [0] * len(stocks)
|
|
315
|
+
offsets = [0] * len(parts)
|
|
316
|
+
layouts = []
|
|
317
|
+
order = sorted(range(len(parts)), key=lambda i: (sizes[i][0] * sizes[i][1], max(sizes[i])), reverse=True)
|
|
318
|
+
if attempt == 1:
|
|
319
|
+
order.sort(key=lambda i: max(sizes[i]), reverse=True)
|
|
320
|
+
elif attempt == 2:
|
|
321
|
+
order.sort(key=lambda i: min(sizes[i]), reverse=True)
|
|
322
|
+
elif attempt > 2:
|
|
323
|
+
random.shuffle(order)
|
|
324
|
+
while any(remaining) and not runtime.stopped(requested - sum(remaining), attempt):
|
|
325
|
+
candidates = []
|
|
326
|
+
for stock_index, stock in enumerate(stocks):
|
|
327
|
+
if stock.count and consumed[stock_index] >= stock.count:
|
|
328
|
+
continue
|
|
329
|
+
root, used = _pack(stock, stock_sizes[stock_index], remaining, parts, sizes, order,
|
|
330
|
+
options, runtime, attempt, requested - sum(remaining))
|
|
331
|
+
if not any(used):
|
|
332
|
+
continue
|
|
333
|
+
layout = _layout(stock_index, consumed[stock_index], stock, root, offsets, parts, options, scale)
|
|
334
|
+
used_area = sum(p.width * p.height for p in layout.parts)
|
|
335
|
+
repeated = options.use_layout_minimization and any(_signature(old) == _signature(layout) for old in layouts)
|
|
336
|
+
stock_area = layout.width * layout.height
|
|
337
|
+
size_score = -stock_area if options.use_large_stock_first else stock_area / used_area
|
|
338
|
+
candidates.append(((not stock.waste, -int(repeated), size_score, -used_area, stock_index), layout, used))
|
|
339
|
+
if runtime.stopped(requested - sum(remaining), attempt):
|
|
340
|
+
break
|
|
341
|
+
if not candidates:
|
|
342
|
+
break
|
|
343
|
+
_, layout, used = min(candidates, key=lambda item: item[0])
|
|
344
|
+
layouts.append(layout)
|
|
345
|
+
consumed[layout.input_index] += 1
|
|
346
|
+
remaining = [n - u for n, u in zip(remaining, used)]
|
|
347
|
+
offsets = [n + u for n, u in zip(offsets, used)]
|
|
348
|
+
area = sum(layout.width * layout.height for layout in layouts)
|
|
349
|
+
patterns = len({_signature(layout) for layout in layouts})
|
|
350
|
+
score = (sum(remaining), patterns if options.use_layout_minimization else 0, area, len(layouts),
|
|
351
|
+
sum(max((c.level for c in layout.cuts), default=0) for layout in layouts))
|
|
352
|
+
if best_score is None or score < best_score:
|
|
353
|
+
best_score, best_layouts, best_unplaced = score, layouts, remaining
|
|
354
|
+
if runtime.stopped(requested - sum(remaining), attempt):
|
|
355
|
+
break
|
|
356
|
+
return Result(tuple(best_layouts), tuple(Unplaced(i, n, parts[i].id) for i, n in enumerate(best_unplaced) if n),
|
|
357
|
+
runtime.elapsed, False, runtime.cancelled, runtime.timed_out, attempts)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""可移植的版本化任务和结果格式;不接受非有限数值及 XML 实体声明。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from .models import (Cut, Layout, LayoutGroup, Material, Options, Part, Placement, Remnant,
|
|
10
|
+
Result, Stock, StockComponent, Unplaced, ValidationError,
|
|
11
|
+
UnsupportedFeatureError)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _check(value):
|
|
15
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
16
|
+
raise ValidationError("序列化数据必须为有限数值")
|
|
17
|
+
if isinstance(value, str) and re.search(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]", value):
|
|
18
|
+
raise ValidationError("字符串包含非法 XML 或 Unicode 字符")
|
|
19
|
+
if isinstance(value, dict):
|
|
20
|
+
for key, item in value.items():
|
|
21
|
+
_check(key)
|
|
22
|
+
_check(item)
|
|
23
|
+
elif isinstance(value, (list, tuple)):
|
|
24
|
+
for item in value:
|
|
25
|
+
_check(item)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dump(data):
|
|
29
|
+
_check(data)
|
|
30
|
+
return json.dumps(data, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(text):
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(text, parse_constant=lambda _: (_ for _ in ()).throw(ValueError("非有限数值")))
|
|
36
|
+
_check(data)
|
|
37
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
38
|
+
raise ValidationError("不支持的文档版本")
|
|
39
|
+
return data
|
|
40
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
41
|
+
raise ValidationError(f"JSON 数据无效: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def job_to_json(stocks, parts, options=None, materials=()):
|
|
45
|
+
"""输出独立 Python 包的版本化任务格式。"""
|
|
46
|
+
return _dump(dict(version=1, kind="job", stocks=[asdict(s) for s in stocks],
|
|
47
|
+
parts=[asdict(p) for p in parts], options=asdict(options or Options()),
|
|
48
|
+
materials=[asdict(m) for m in materials]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def job_from_json(text):
|
|
52
|
+
"""返回 (stocks, parts, options, materials),均为公开模型实例。"""
|
|
53
|
+
data = _load(text)
|
|
54
|
+
try:
|
|
55
|
+
if data.get("kind") != "job":
|
|
56
|
+
raise ValidationError("文档不是任务")
|
|
57
|
+
return ([Stock(**s) for s in data["stocks"]], [Part(**p) for p in data["parts"]],
|
|
58
|
+
Options(**data["options"]), [Material(**m) for m in data["materials"]])
|
|
59
|
+
except (KeyError, TypeError) as exc:
|
|
60
|
+
raise ValidationError(f"任务结构无效: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def result_to_json(result):
|
|
64
|
+
return _dump(dict(version=1, kind="result", result=asdict(result)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def result_from_json(text):
|
|
68
|
+
data = _load(text)
|
|
69
|
+
try:
|
|
70
|
+
if data.get("kind") != "result":
|
|
71
|
+
raise ValidationError("文档不是结果")
|
|
72
|
+
value = data["result"]
|
|
73
|
+
layouts = []
|
|
74
|
+
for row in value.pop("layouts"):
|
|
75
|
+
parts = []
|
|
76
|
+
for p in row.pop("parts"):
|
|
77
|
+
p["polygon"] = tuple(tuple(pt) for pt in p.get("polygon", ()))
|
|
78
|
+
parts.append(Placement(**p))
|
|
79
|
+
row["parts"] = tuple(parts)
|
|
80
|
+
for name in ("remnants", "roll_remnants"):
|
|
81
|
+
row[name] = tuple(Remnant(**r) for r in row.get(name, ()))
|
|
82
|
+
for name in ("cuts", "trim_cuts"):
|
|
83
|
+
cuts = []
|
|
84
|
+
for c in row.get(name, ()):
|
|
85
|
+
c["part_indices"] = tuple(c.get("part_indices", ()))
|
|
86
|
+
cuts.append(Cut(**c))
|
|
87
|
+
row[name] = tuple(cuts)
|
|
88
|
+
row["components"] = tuple(StockComponent(**c) for c in row.get("components", ()))
|
|
89
|
+
layouts.append(Layout(**row))
|
|
90
|
+
value["layouts"] = tuple(layouts)
|
|
91
|
+
value["unplaced"] = tuple(Unplaced(**u) for u in value.get("unplaced", ()))
|
|
92
|
+
value["layout_groups"] = tuple(LayoutGroup(**g) for g in value.get("layout_groups", ()))
|
|
93
|
+
return Result(**value)
|
|
94
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
95
|
+
raise ValidationError(f"结果结构无效: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _to_xml(text):
|
|
99
|
+
# 每个字段保存带类型 JSON 值,保留空值、布尔值、浮点数和任意中文 ID。
|
|
100
|
+
data = _load(text)
|
|
101
|
+
root = ET.Element("ECodeCutting", version="1", kind=data["kind"])
|
|
102
|
+
for key, value in data.items():
|
|
103
|
+
if key not in ("version", "kind"):
|
|
104
|
+
ET.SubElement(root, key, encoding="json").text = _dump(value)
|
|
105
|
+
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _from_xml(text):
|
|
109
|
+
if isinstance(text, bytes):
|
|
110
|
+
try:
|
|
111
|
+
text = text.decode("utf-8-sig")
|
|
112
|
+
except UnicodeError as exc:
|
|
113
|
+
raise ValidationError("XML 必须为 UTF-8") from exc
|
|
114
|
+
if not isinstance(text, str) or re.search(r"<!\s*(DOCTYPE|ENTITY)", text, re.I):
|
|
115
|
+
raise ValidationError("不允许 XML DTD 或实体声明")
|
|
116
|
+
try:
|
|
117
|
+
root = ET.fromstring(text)
|
|
118
|
+
if root.tag != "ECodeCutting" or root.get("version") != "1":
|
|
119
|
+
raise ValidationError("不支持的 XML 文档")
|
|
120
|
+
data = dict(version=1, kind=root.get("kind"))
|
|
121
|
+
for child in root:
|
|
122
|
+
if child.tag in data or child.get("encoding") != "json":
|
|
123
|
+
raise ValidationError("重复或不支持的 XML 字段")
|
|
124
|
+
data[child.tag] = json.loads(child.text or "null")
|
|
125
|
+
return _dump(data)
|
|
126
|
+
except (ET.ParseError, ValueError, TypeError) as exc:
|
|
127
|
+
raise ValidationError(f"XML 数据无效: {exc}") from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def job_to_xml(stocks, parts, options=None, materials=()):
|
|
131
|
+
return _to_xml(job_to_json(stocks, parts, options, materials))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def job_from_xml(text):
|
|
135
|
+
# 原 CutGLib 的 Stocks/Parts/Part_N 属性形式也是可读取的任务输入。
|
|
136
|
+
if isinstance(text, bytes):
|
|
137
|
+
try:
|
|
138
|
+
text = text.decode("utf-8-sig")
|
|
139
|
+
except UnicodeError as exc:
|
|
140
|
+
raise ValidationError("XML 必须为 UTF-8") from exc
|
|
141
|
+
if not isinstance(text, str) or re.search(r"<!\s*(DOCTYPE|ENTITY)",text,re.I):
|
|
142
|
+
raise ValidationError("不允许 XML DTD 或实体声明")
|
|
143
|
+
try:
|
|
144
|
+
root=ET.fromstring(text)
|
|
145
|
+
except ET.ParseError as exc:
|
|
146
|
+
raise ValidationError(f"XML 数据无效: {exc}") from exc
|
|
147
|
+
if root.tag=="ECodeCutting":
|
|
148
|
+
return job_from_json(_from_xml(text))
|
|
149
|
+
return _legacy_job(root)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _legacy_job(root):
|
|
153
|
+
from fractions import Fraction
|
|
154
|
+
def dimension(value):
|
|
155
|
+
if value in (None,"","NULL"): return 0.0
|
|
156
|
+
try:
|
|
157
|
+
# DimensionValue 的英尺英寸存储为总英寸分数,例如 305/2";
|
|
158
|
+
# Units 仅控制显示,不额外乘以毫米或厘米换算系数。
|
|
159
|
+
value=value.translate(str.maketrans({c:"'" for c in "‘`‛‚’"}|{c:'"' for c in "“”„‟"})).strip()
|
|
160
|
+
feet=Fraction(0)
|
|
161
|
+
if "'" in value:
|
|
162
|
+
left,value=value.split("'",1)
|
|
163
|
+
feet=Fraction(left.strip())*12
|
|
164
|
+
if '"' in value:
|
|
165
|
+
if not value.endswith('"') or value.count('"')!=1:
|
|
166
|
+
raise ValueError("英寸标记位置错误")
|
|
167
|
+
value=value[:-1].strip()
|
|
168
|
+
pieces=value.split()
|
|
169
|
+
result=float(feet+sum((Fraction(p) for p in pieces),Fraction(0)))
|
|
170
|
+
_check(result)
|
|
171
|
+
return result
|
|
172
|
+
except (ValueError,ZeroDivisionError) as exc:
|
|
173
|
+
raise ValidationError("旧 XML 尺寸无效") from exc
|
|
174
|
+
def boolean(value):
|
|
175
|
+
if value.lower() in ("true","1","yes"): return True
|
|
176
|
+
if value.lower() in ("false","0","no"): return False
|
|
177
|
+
raise ValidationError("旧 XML 布尔值无效")
|
|
178
|
+
if root.find("Parts") is None:
|
|
179
|
+
raise ValidationError("未识别到 CutGLib XML Parts 集合")
|
|
180
|
+
holes=root.find("Holes")
|
|
181
|
+
if holes is not None and (len(holes) or int(holes.get("Count","0"))):
|
|
182
|
+
raise UnsupportedFeatureError("当前公开模型未提供旧 XML 排除区域")
|
|
183
|
+
options=Options()
|
|
184
|
+
dimension_names={"SawWidth":"saw_width","TrimLeft":"trim_left","TrimTop":"trim_top",
|
|
185
|
+
"TrimRight":"trim_right","TrimBottom":"trim_bottom","WasteSizeMin":"waste_size_min",
|
|
186
|
+
"CrossSection":"cross_section","LinearMaxSizePerStockThreshold":"linear_max_size_per_stock_threshold"}
|
|
187
|
+
integer_names={"MaxCutLevel":"max_cut_level","MaxPatternSize":"max_layout_size",
|
|
188
|
+
"LinearMaxSizePerStock":"linear_max_size_per_stock","MaxSizePerStock":"max_size_per_stock"}
|
|
189
|
+
boolean_names={"CompleteMode":"complete_mode","LinearAllowRotate":"linear_allow_rotate",
|
|
190
|
+
"LinearExactAngle":"linear_exact_angle","LinearAllowFlipping":"linear_allow_flipping",
|
|
191
|
+
"RollMode":"roll_mode","AllowCombineStockPriority":"allow_combine_stock_regular_and_waste"}
|
|
192
|
+
try:
|
|
193
|
+
for old,new in dimension_names.items():
|
|
194
|
+
if old in root.attrib: setattr(options,new,dimension(root.get(old)))
|
|
195
|
+
for old,new in integer_names.items():
|
|
196
|
+
if old in root.attrib: setattr(options,new,int(root.get(old)))
|
|
197
|
+
for old,new in boolean_names.items():
|
|
198
|
+
if old in root.attrib: setattr(options,new,boolean(root.get(old)))
|
|
199
|
+
options.use_layout_minimization=dimension(root.get("PatternScale"))>0
|
|
200
|
+
options.cut_direction={1:"horizontal",2:"vertical",3:"auto"}[int(root.get("Orientation","3"))]
|
|
201
|
+
def records(tag,cls):
|
|
202
|
+
group=root.find(tag)
|
|
203
|
+
if group is None: return []
|
|
204
|
+
if int(group.get("Count",str(len(group))))!=len(group):
|
|
205
|
+
raise ValidationError("旧 XML 集合 Count 与实际数量不一致")
|
|
206
|
+
result=[]
|
|
207
|
+
for element in group:
|
|
208
|
+
if not re.fullmatch(r"Part_\d+",element.tag):
|
|
209
|
+
raise ValidationError("旧 XML 集合记录名无效")
|
|
210
|
+
if not boolean(element.get("Active","true")): continue
|
|
211
|
+
data=dict(width=dimension(element.get("Width")),height=dimension(element.get("Height")),
|
|
212
|
+
count=int(element.get("Count","1")),id=element.get("ID",""),material=int(element.get("Material","-1")))
|
|
213
|
+
if cls is Part:
|
|
214
|
+
data.update(rotatable=boolean(element.get("Rotatable","true")),
|
|
215
|
+
angle_start=dimension(element.get("AngleStart")),angle_end=dimension(element.get("AngleEnd")))
|
|
216
|
+
else: data["waste"]=int(element.get("Priority","0"))!=0
|
|
217
|
+
result.append(cls(**data))
|
|
218
|
+
return result
|
|
219
|
+
stocks=records("Stocks",Stock)
|
|
220
|
+
if not stocks and root.find("Stock") is not None:
|
|
221
|
+
default=root.find("Stock")
|
|
222
|
+
stocks=[Stock(dimension(default.get("Width")),dimension(default.get("Height")),0,default.get("ID",""),material=int(default.get("Material","-1")))]
|
|
223
|
+
parts=records("Parts",Part)
|
|
224
|
+
return stocks,parts,options,[]
|
|
225
|
+
except (TypeError,ValueError,KeyError) as exc:
|
|
226
|
+
raise ValidationError(f"旧 XML 任务无效: {exc}") from exc
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def result_to_xml(result):
|
|
230
|
+
return _to_xml(result_to_json(result))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def result_from_xml(text):
|
|
234
|
+
return result_from_json(_from_xml(text))
|