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,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))