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/engine.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""独立任务引擎:管理输入、调用求解器并查询结果,不依赖 Web 框架。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import asdict, fields, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from ._common import Runtime, integer, normalize_options, number, validate
|
|
12
|
+
from .models import (
|
|
13
|
+
Cut, Layout, LayoutGroup, Material, Options, Part, Placement, Progress,
|
|
14
|
+
Remnant, Result, Stock, StockComponent, ValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pascal(name: str) -> str:
|
|
19
|
+
return "".join(word.capitalize() for word in name.split("_"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_OPTION_NAMES = {_pascal(f.name): f.name for f in fields(Options)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _layout_key(layout: Layout) -> tuple:
|
|
26
|
+
"""不以零件编号分组;逐件溯源仍完整保存在每一张布局中。"""
|
|
27
|
+
return (layout.input_index, layout.width, layout.height,
|
|
28
|
+
tuple(sorted((p.width, p.height, p.x, p.y, p.rotated, p.flipped,
|
|
29
|
+
p.angle_start, p.angle_end, p.material) for p in layout.parts)),
|
|
30
|
+
tuple((c.x1, c.y1, c.x2, c.y2, c.level) for c in layout.cuts))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def group_layouts(result: Result, options: Options) -> Result:
|
|
34
|
+
groups: dict[tuple, list[Layout]] = defaultdict(list)
|
|
35
|
+
for layout in result.layouts:
|
|
36
|
+
groups[_layout_key(layout)].append(layout)
|
|
37
|
+
layouts: list[Layout] = []
|
|
38
|
+
summaries: list[LayoutGroup] = []
|
|
39
|
+
for group in groups.values():
|
|
40
|
+
limit = (options.max_layout_size if options.use_layout_minimization else 0) or len(group)
|
|
41
|
+
for start in range(0, len(group), limit):
|
|
42
|
+
chunk = group[start:start + limit]
|
|
43
|
+
summaries.append(LayoutGroup(len(layouts), len(chunk)))
|
|
44
|
+
layouts.extend(chunk)
|
|
45
|
+
return replace(result, layouts=tuple(layouts), layout_groups=tuple(summaries))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CutEngine:
|
|
49
|
+
"""一个实例表示一个切割任务;Web 的不同请求应分别创建实例。
|
|
50
|
+
|
|
51
|
+
Python 接口返回数据模型,非法参数抛 ValidationError。旧 PascalCase 名称
|
|
52
|
+
作为迁移别名保留,但不仿造 .NET 的 out 参数和授权注册接口。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
version = "0.2.3"
|
|
56
|
+
|
|
57
|
+
def __init__(self, options: Options | None = None, **settings):
|
|
58
|
+
self.options = normalize_options(options) if options is not None else Options()
|
|
59
|
+
self._parts: list[Part] = []
|
|
60
|
+
self._stocks: list[Stock] = []
|
|
61
|
+
self._materials: list[Material] = []
|
|
62
|
+
self._result: Result | None = None
|
|
63
|
+
self._result_options: Options | None = None
|
|
64
|
+
self._locations_cache: list[tuple[int, Placement] | None] | None = None
|
|
65
|
+
self._lock = Lock()
|
|
66
|
+
self._running = False
|
|
67
|
+
for name, value in settings.items():
|
|
68
|
+
field_name = _OPTION_NAMES.get(name, name)
|
|
69
|
+
if field_name not in Options.__dataclass_fields__:
|
|
70
|
+
raise TypeError(f"未知计算参数:{name}")
|
|
71
|
+
setattr(self.options, field_name, value)
|
|
72
|
+
self.options = normalize_options(self.options)
|
|
73
|
+
|
|
74
|
+
def __getattr__(self, name):
|
|
75
|
+
if name in _OPTION_NAMES:
|
|
76
|
+
return getattr(self.options, _OPTION_NAMES[name])
|
|
77
|
+
raise AttributeError(name)
|
|
78
|
+
|
|
79
|
+
def __setattr__(self, name, value):
|
|
80
|
+
if name in _OPTION_NAMES and "options" in self.__dict__:
|
|
81
|
+
setattr(self.options, _OPTION_NAMES[name], value)
|
|
82
|
+
else:
|
|
83
|
+
object.__setattr__(self, name, value)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def parts(self) -> tuple[Part, ...]:
|
|
87
|
+
return tuple(self._parts)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def stocks(self) -> tuple[Stock, ...]:
|
|
91
|
+
return tuple(self._stocks)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def materials(self) -> tuple[Material, ...]:
|
|
95
|
+
return tuple(self._materials)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def result(self) -> Result:
|
|
99
|
+
if self._result is None:
|
|
100
|
+
raise RuntimeError("请先执行 execute() 或 execute_linear()")
|
|
101
|
+
return self._result
|
|
102
|
+
|
|
103
|
+
def _modify(self, action):
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._running:
|
|
106
|
+
raise RuntimeError("同一引擎正在计算,不能同时修改输入")
|
|
107
|
+
value = action()
|
|
108
|
+
self._result = None
|
|
109
|
+
self._result_options = None
|
|
110
|
+
self._locations_cache = None
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
def clear(self, *, reset_options: bool = False) -> None:
|
|
114
|
+
"""清空输入和结果;原生接口默认保留选项,文档兼容入口另行重置。"""
|
|
115
|
+
def reset():
|
|
116
|
+
self._parts.clear()
|
|
117
|
+
self._stocks.clear()
|
|
118
|
+
self._materials.clear()
|
|
119
|
+
if reset_options:
|
|
120
|
+
self.options = Options()
|
|
121
|
+
self._modify(reset)
|
|
122
|
+
|
|
123
|
+
def add_material(self, id: str = "", description: str = "", thickness: float = 0.0) -> int:
|
|
124
|
+
if not isinstance(id, str) or not isinstance(description, str):
|
|
125
|
+
raise ValidationError("材料编号和描述必须是字符串")
|
|
126
|
+
thickness = number(thickness, "thickness")
|
|
127
|
+
def append():
|
|
128
|
+
self._materials.append(Material(id, description, thickness))
|
|
129
|
+
return len(self._materials) - 1
|
|
130
|
+
return self._modify(append)
|
|
131
|
+
|
|
132
|
+
def get_material(self, material: int) -> Material:
|
|
133
|
+
return self._at(self._materials, material)
|
|
134
|
+
|
|
135
|
+
def add_part(self, width: float, height: float, count: int = 1,
|
|
136
|
+
rotatable: bool = True, id: str = "", material: int = -1) -> bool:
|
|
137
|
+
part = Part(number(width, "width", positive=True), number(height, "height", positive=True),
|
|
138
|
+
count, rotatable, id, material)
|
|
139
|
+
validate((), (part,), Options(max_decimal_point=6), linear=False)
|
|
140
|
+
self._modify(lambda: self._parts.append(part))
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def add_stock(self, width: float, height: float, count: int = 1,
|
|
144
|
+
id: str = "", waste: bool = False, material: int = -1) -> bool:
|
|
145
|
+
stock = Stock(number(width, "width", positive=True), number(height, "height", positive=True),
|
|
146
|
+
count, id, waste, material)
|
|
147
|
+
validate((stock,), (), Options(max_decimal_point=6), linear=False)
|
|
148
|
+
self._modify(lambda: self._stocks.append(stock))
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
def add_linear_part(self, length: float, count: int = 1, *, id: str = "",
|
|
152
|
+
material: int = -1, angle_start: float = 0.0,
|
|
153
|
+
angle_end: float = 0.0) -> bool:
|
|
154
|
+
part = Part(number(length, "length", positive=True), 1.0, count, False, id, material,
|
|
155
|
+
number(angle_start, "angle_start"), number(angle_end, "angle_end"))
|
|
156
|
+
# 输入阶段允许稍后设置截面和更细的计算精度,最终执行时统一核验。
|
|
157
|
+
validate((), (part,), Options(cross_section=1, max_decimal_point=6), linear=True)
|
|
158
|
+
self._modify(lambda: self._parts.append(part))
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def add_linear_stock(self, length: float, count: int = 1, *, id: str = "",
|
|
162
|
+
waste: bool = False, material: int = -1) -> bool:
|
|
163
|
+
stock = Stock(number(length, "length", positive=True), 1.0, count, id, waste, material)
|
|
164
|
+
validate((stock,), (), Options(max_decimal_point=6), linear=True)
|
|
165
|
+
self._modify(lambda: self._stocks.append(stock))
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def set_vertical_cut_direction(self, roll_mode: bool = False) -> None:
|
|
169
|
+
self._set_direction("vertical", roll_mode)
|
|
170
|
+
|
|
171
|
+
def set_horizontal_cut_direction(self, roll_mode: bool = False) -> None:
|
|
172
|
+
self._set_direction("horizontal", roll_mode)
|
|
173
|
+
|
|
174
|
+
def set_auto_cut_direction(self) -> None:
|
|
175
|
+
self._set_direction("auto", False)
|
|
176
|
+
|
|
177
|
+
def _set_direction(self, direction: str, roll_mode: bool):
|
|
178
|
+
if type(roll_mode) is not bool:
|
|
179
|
+
raise ValidationError("roll_mode 必须是布尔值")
|
|
180
|
+
def change():
|
|
181
|
+
self.options.cut_direction = direction
|
|
182
|
+
self.options.roll_mode = roll_mode
|
|
183
|
+
self._modify(change)
|
|
184
|
+
|
|
185
|
+
def execute(self, callback: Callable[[Progress], bool] | None = None) -> Result:
|
|
186
|
+
return self._execute(False, callback)
|
|
187
|
+
|
|
188
|
+
def execute_linear(self, callback: Callable[[Progress], bool] | None = None) -> Result:
|
|
189
|
+
return self._execute(True, callback)
|
|
190
|
+
|
|
191
|
+
def execute_linear_fast(self, callback: Callable[[Progress], bool] | None = None) -> Result:
|
|
192
|
+
"""新增快速入口;复杂工艺自动使用完整求解器,原 execute_linear 行为不变。"""
|
|
193
|
+
from ._linear_fast import solve_linear_fast
|
|
194
|
+
return self._execute(True, callback, solver=solve_linear_fast)
|
|
195
|
+
|
|
196
|
+
def optimize_linear_plans(self, *, max_plans: int = 3, time_limit: float = 5.0,
|
|
197
|
+
max_searches: int = 12,
|
|
198
|
+
callback: Callable[[Progress], bool] | None = None):
|
|
199
|
+
"""在共享预算内返回多种一维方案,不覆盖本引擎已有计算结果。"""
|
|
200
|
+
return self._optimize_plans(True, max_plans, time_limit, max_searches, callback)
|
|
201
|
+
|
|
202
|
+
def optimize_plans(self, *, max_plans: int = 3, time_limit: float = 5.0,
|
|
203
|
+
max_searches: int = 12,
|
|
204
|
+
callback: Callable[[Progress], bool] | None = None):
|
|
205
|
+
"""在共享预算内返回多种二维方案;物理工艺约束在各方案中保持不变。"""
|
|
206
|
+
return self._optimize_plans(False, max_plans, time_limit, max_searches, callback)
|
|
207
|
+
|
|
208
|
+
def _optimize_plans(self, linear, max_plans, time_limit, max_searches, callback):
|
|
209
|
+
from .portfolio import search_portfolio
|
|
210
|
+
with self._lock:
|
|
211
|
+
if self._running:
|
|
212
|
+
raise RuntimeError("同一引擎不能同时执行两个计算,请为每个请求新建实例")
|
|
213
|
+
options, stocks, parts = normalize_options(self.options), self.stocks, self.parts
|
|
214
|
+
self._running = True
|
|
215
|
+
try:
|
|
216
|
+
return search_portfolio(stocks, parts, options, linear=linear, max_plans=max_plans,
|
|
217
|
+
time_limit=time_limit, max_searches=max_searches, callback=callback)
|
|
218
|
+
finally:
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._running = False
|
|
221
|
+
|
|
222
|
+
def _execute(self, linear: bool, callback, *, solver=None) -> Result:
|
|
223
|
+
with self._lock:
|
|
224
|
+
if self._running:
|
|
225
|
+
raise RuntimeError("同一引擎不能同时执行两个计算,请为每个请求新建实例")
|
|
226
|
+
# 快照可以因公开配置被赋错类型而失败;在置忙之前完成,保证任务仍可清空重试。
|
|
227
|
+
options = normalize_options(self.options)
|
|
228
|
+
stocks, parts = self.stocks, self.parts
|
|
229
|
+
self._running = True
|
|
230
|
+
self._result = None
|
|
231
|
+
self._locations_cache = None
|
|
232
|
+
try:
|
|
233
|
+
validate(stocks, parts, options, linear=linear)
|
|
234
|
+
if callback is not None and not callable(callback):
|
|
235
|
+
raise ValidationError("callback 必须可调用")
|
|
236
|
+
runtime = Runtime(options, sum(p.count for p in parts), callback)
|
|
237
|
+
if solver is not None:
|
|
238
|
+
result = solver(stocks, parts, options, runtime)
|
|
239
|
+
elif linear:
|
|
240
|
+
from .linear import solve_linear
|
|
241
|
+
result = solve_linear(stocks, parts, options, runtime)
|
|
242
|
+
else:
|
|
243
|
+
from .rectangular import solve_rectangular
|
|
244
|
+
result = solve_rectangular(stocks, parts, options, runtime)
|
|
245
|
+
self._result = group_layouts(result, options)
|
|
246
|
+
self._result_options = options
|
|
247
|
+
return self._result
|
|
248
|
+
finally:
|
|
249
|
+
with self._lock:
|
|
250
|
+
self._running = False
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _at(items, index):
|
|
254
|
+
if type(index) is not int or not 0 <= index < len(items):
|
|
255
|
+
raise IndexError(f"索引超出范围:{index}")
|
|
256
|
+
return items[index]
|
|
257
|
+
|
|
258
|
+
def _part_locations(self) -> list[tuple[int, Placement] | None]:
|
|
259
|
+
if self._locations_cache is not None:
|
|
260
|
+
return self._locations_cache
|
|
261
|
+
locations = {(p.input_index, p.instance_index): (s, p)
|
|
262
|
+
for s, layout in enumerate(self.result.layouts) for p in layout.parts}
|
|
263
|
+
self._locations_cache = [locations.get((i, n)) for i, part in enumerate(self._parts) for n in range(part.count)]
|
|
264
|
+
return self._locations_cache
|
|
265
|
+
|
|
266
|
+
def get_result_part(self, part: int) -> tuple[int, Placement] | None:
|
|
267
|
+
"""按输入展开序号查询;未放置返回 None,避免重排后丢失订单映射。"""
|
|
268
|
+
return self._at(self._part_locations(), part)
|
|
269
|
+
|
|
270
|
+
get_result_linear_part = get_result_part
|
|
271
|
+
|
|
272
|
+
def get_stock_info(self, stock: int) -> Layout:
|
|
273
|
+
return self._at(self.result.layouts, stock)
|
|
274
|
+
|
|
275
|
+
get_linear_stock_info = get_stock_info
|
|
276
|
+
|
|
277
|
+
def get_part_count_on_stock(self, stock: int) -> int:
|
|
278
|
+
return len(self.get_stock_info(stock).parts)
|
|
279
|
+
|
|
280
|
+
def get_part_index_on_stock(self, stock: int, part: int) -> int:
|
|
281
|
+
placement = self._at(self.get_stock_info(stock).parts, part)
|
|
282
|
+
return sum(p.count for p in self._parts[:placement.input_index]) + placement.instance_index
|
|
283
|
+
|
|
284
|
+
def get_part_material(self, part: int) -> int:
|
|
285
|
+
self._at(self._part_locations(), part)
|
|
286
|
+
for p in self._parts:
|
|
287
|
+
if part < p.count:
|
|
288
|
+
return p.material
|
|
289
|
+
part -= p.count
|
|
290
|
+
raise IndexError(part)
|
|
291
|
+
|
|
292
|
+
def get_stock_material(self, stock: int) -> int:
|
|
293
|
+
return self.get_stock_info(stock).material
|
|
294
|
+
|
|
295
|
+
def _remnants(self) -> list[tuple[int, Remnant]]:
|
|
296
|
+
return [(s, r) for s, layout in enumerate(self.result.layouts) for r in layout.all_remnants]
|
|
297
|
+
|
|
298
|
+
def get_remaining_part(self, part: int) -> tuple[int, Remnant]:
|
|
299
|
+
return self._at(self._remnants(), part)
|
|
300
|
+
|
|
301
|
+
get_remaining_linear_part = get_remaining_part
|
|
302
|
+
|
|
303
|
+
def get_remaining_part_material(self, part: int) -> int:
|
|
304
|
+
return self.get_remaining_part(part)[1].material
|
|
305
|
+
|
|
306
|
+
def get_remaining_part_count_on_stock(self, stock: int) -> int:
|
|
307
|
+
return len(self.get_stock_info(stock).all_remnants)
|
|
308
|
+
|
|
309
|
+
def get_remaining_part_index_on_stock(self, stock: int, part: int) -> int:
|
|
310
|
+
self._at(self.get_stock_info(stock).all_remnants, part)
|
|
311
|
+
return sum(len(s.all_remnants) for s in self.result.layouts[:stock]) + part
|
|
312
|
+
|
|
313
|
+
def _cuts(self) -> list[tuple[int, Cut]]:
|
|
314
|
+
return [(s, c) for s, layout in enumerate(self.result.layouts) for c in layout.cuts]
|
|
315
|
+
|
|
316
|
+
def get_cuts_count(self) -> int:
|
|
317
|
+
return sum(len(s.cuts) for s in self.result.layouts)
|
|
318
|
+
|
|
319
|
+
get_linear_cuts_count = get_cuts_count
|
|
320
|
+
|
|
321
|
+
def get_cut(self, cut: int) -> tuple[int, Cut]:
|
|
322
|
+
return self._at(self._cuts(), cut)
|
|
323
|
+
|
|
324
|
+
get_linear_cut = get_cut
|
|
325
|
+
|
|
326
|
+
def get_stock_cut_count(self, stock: int) -> int:
|
|
327
|
+
return len(self.get_stock_info(stock).cuts)
|
|
328
|
+
|
|
329
|
+
def get_stock_cut(self, stock: int, cut: int) -> Cut:
|
|
330
|
+
return self._at(self.get_stock_info(stock).cuts, cut)
|
|
331
|
+
|
|
332
|
+
get_linear_stock_cut = get_stock_cut
|
|
333
|
+
|
|
334
|
+
def get_stock_trim_cut_count(self, stock: int) -> int:
|
|
335
|
+
return len(self.get_stock_info(stock).trim_cuts)
|
|
336
|
+
|
|
337
|
+
def get_stock_trim_cut(self, stock: int, cut: int) -> Cut:
|
|
338
|
+
return self._at(self.get_stock_info(stock).trim_cuts, cut)
|
|
339
|
+
|
|
340
|
+
def _part_cuts(self, part: int) -> tuple[int, list[Cut]]:
|
|
341
|
+
located = self.get_result_part(part)
|
|
342
|
+
if located is None:
|
|
343
|
+
return -1, []
|
|
344
|
+
stock, placement = located
|
|
345
|
+
layout = self.result.layouts[stock]
|
|
346
|
+
index = layout.parts.index(placement)
|
|
347
|
+
cuts = [c for c in layout.cuts if index in c.part_indices]
|
|
348
|
+
return stock, cuts
|
|
349
|
+
|
|
350
|
+
def get_part_cuts_count(self, part: int) -> int:
|
|
351
|
+
return len(self._part_cuts(part)[1])
|
|
352
|
+
|
|
353
|
+
def get_part_cut(self, part: int, cut: int) -> tuple[int, Cut]:
|
|
354
|
+
stock, cuts = self._part_cuts(part)
|
|
355
|
+
return stock, self._at(cuts, cut)
|
|
356
|
+
|
|
357
|
+
def get_linear_combine_stock_count(self, stock: int) -> int:
|
|
358
|
+
components = self.get_stock_info(stock).components
|
|
359
|
+
return len(components) if len(components) > 1 else 0
|
|
360
|
+
|
|
361
|
+
def get_linear_combine_stock_components(self, stock: int) -> tuple[StockComponent, ...]:
|
|
362
|
+
layout = self.get_stock_info(stock)
|
|
363
|
+
return layout.components or (StockComponent(layout.input_index, layout.instance_index,
|
|
364
|
+
layout.width, layout.id, layout.waste, layout.material),)
|
|
365
|
+
|
|
366
|
+
def get_linear_combine_stock_info(self, stock: int, index: int) -> StockComponent:
|
|
367
|
+
return self._at(self.get_linear_combine_stock_components(stock), index)
|
|
368
|
+
|
|
369
|
+
def get_layout_info(self, layout: int) -> LayoutGroup:
|
|
370
|
+
return self._at(self.result.layout_groups, layout)
|
|
371
|
+
|
|
372
|
+
def get_layout_fill_ratio(self, layout: int) -> float:
|
|
373
|
+
group = self.get_layout_info(layout)
|
|
374
|
+
stock = self.result.layouts[group.first_layout_index]
|
|
375
|
+
if self._result_options is not None and self._result_options.cross_section > 0:
|
|
376
|
+
return (stock.width - sum(r.width for r in stock.remnants)) / stock.width
|
|
377
|
+
return stock.fill_ratio
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def part_count(self) -> int:
|
|
381
|
+
return sum(p.count for p in self._parts)
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def remaining_part_count(self) -> int:
|
|
385
|
+
return len(self._remnants())
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def stock_count(self) -> int:
|
|
389
|
+
return len(self.result.layouts)
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def used_stock_count(self) -> int:
|
|
393
|
+
return self.result.used_stock_count
|
|
394
|
+
|
|
395
|
+
linear_stock_count = stock_count
|
|
396
|
+
used_linear_stock_count = used_stock_count
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def layout_count(self) -> int:
|
|
400
|
+
return len(self.result.layout_groups)
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def elapsed_time(self) -> float:
|
|
404
|
+
return self.result.elapsed
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def placed_part_count(self) -> int:
|
|
408
|
+
return self.result.placed_part_count
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def is_linear_run(self) -> bool:
|
|
412
|
+
return self._result is not None and self._result.linear
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def max_part_sizes(self) -> None:
|
|
416
|
+
"""零件规格类数量不受旧产品授权上限限制;None 表示无限制。"""
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def max_size_sizes(self) -> None:
|
|
421
|
+
"""原料规格类数量不受旧产品授权上限限制;None 表示无限制。"""
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
def export_csv(self, target) -> None:
|
|
425
|
+
from .exporters import export_csv
|
|
426
|
+
export_csv(self.result, target)
|
|
427
|
+
|
|
428
|
+
def export_dxf(self, target, include_part_ids: bool = True) -> None:
|
|
429
|
+
from .exporters import export_dxf
|
|
430
|
+
export_dxf(self.result, target, include_part_ids=include_part_ids)
|
|
431
|
+
|
|
432
|
+
def export_cuts_dxf(self, target, include_cut_number: bool = True,
|
|
433
|
+
include_stock_panel: bool = True) -> None:
|
|
434
|
+
from .exporters import export_dxf
|
|
435
|
+
export_dxf(self.result, target, cuts_only=True, include_cut_number=include_cut_number,
|
|
436
|
+
include_stock_panel=include_stock_panel)
|
|
437
|
+
|
|
438
|
+
def export_ptx(self, target, job_name="", customer="", material="", material_thickness=0,
|
|
439
|
+
units=0, unit_scale=1, require_all_trim_cuts=False) -> None:
|
|
440
|
+
from .exporters import export_ptx
|
|
441
|
+
export_ptx(self.result, target, job_name=job_name, customer=customer, material=material,
|
|
442
|
+
material_thickness=material_thickness, units=units, unit_scale=unit_scale,
|
|
443
|
+
require_all_trim_cuts=require_all_trim_cuts,
|
|
444
|
+
options=self._result_options, materials=self.materials)
|
|
445
|
+
|
|
446
|
+
def create_stock_image(self, stock: int, target, max_size: int = 1000) -> None:
|
|
447
|
+
from .exporters import render_png, render_svg
|
|
448
|
+
layout = self.get_stock_info(stock)
|
|
449
|
+
if isinstance(target, (str, Path)):
|
|
450
|
+
if Path(target).suffix.lower() == ".svg":
|
|
451
|
+
Path(target).write_text(render_svg(layout, linear=self.result.linear, max_size=max_size), encoding="utf-8")
|
|
452
|
+
else:
|
|
453
|
+
Path(target).write_bytes(render_png(layout, linear=self.result.linear, max_size=max_size))
|
|
454
|
+
else:
|
|
455
|
+
target.write(render_png(layout, linear=self.result.linear, max_size=max_size))
|
|
456
|
+
|
|
457
|
+
create_linear_stock_image = create_stock_image
|
|
458
|
+
|
|
459
|
+
def to_dict(self) -> dict:
|
|
460
|
+
"""面向 Web 的任务结构,与版本化存档格式分别提供。"""
|
|
461
|
+
return {"stocks": [asdict(s) for s in self.stocks],
|
|
462
|
+
"parts": [asdict(p) for p in self.parts], "options": asdict(normalize_options(self.options)),
|
|
463
|
+
"materials": [asdict(m) for m in self.materials]}
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
def from_dict(cls, payload: dict) -> CutEngine:
|
|
467
|
+
if not isinstance(payload, dict) or set(payload) - {"stocks", "parts", "options", "materials"}:
|
|
468
|
+
raise ValidationError("任务必须包含 stocks、parts、options、materials 中的字段")
|
|
469
|
+
if not isinstance(payload.get("options", {}), dict):
|
|
470
|
+
raise ValidationError("options 必须是对象")
|
|
471
|
+
for name in ("stocks", "parts", "materials"):
|
|
472
|
+
if not isinstance(payload.get(name, []), list) or any(not isinstance(item, dict) for item in payload.get(name, [])):
|
|
473
|
+
raise ValidationError(f"{name} 必须是对象数组")
|
|
474
|
+
try:
|
|
475
|
+
engine = cls(**payload.get("options", {}))
|
|
476
|
+
for m in payload.get("materials", ()):
|
|
477
|
+
engine.add_material(**m)
|
|
478
|
+
engine._stocks = [Stock(**s) for s in payload.get("stocks", ())]
|
|
479
|
+
engine._parts = [Part(**p) for p in payload.get("parts", ())]
|
|
480
|
+
engine._stocks = [replace(s, width=number(s.width, "stock.width", positive=True),
|
|
481
|
+
height=number(s.height, "stock.height", positive=True)) for s in engine._stocks]
|
|
482
|
+
engine._parts = [replace(p, width=number(p.width, "part.width", positive=True),
|
|
483
|
+
height=number(p.height, "part.height", positive=True),
|
|
484
|
+
angle_start=number(p.angle_start, "angle_start"),
|
|
485
|
+
angle_end=number(p.angle_end, "angle_end")) for p in engine._parts]
|
|
486
|
+
return engine
|
|
487
|
+
except (TypeError, KeyError) as exc:
|
|
488
|
+
raise ValidationError(f"任务结构无效:{exc}") from exc
|
|
489
|
+
|
|
490
|
+
def to_json(self) -> str:
|
|
491
|
+
from .serialization import job_to_json
|
|
492
|
+
return job_to_json(self.stocks, self.parts, self.options, self.materials)
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def from_json(cls, text: str) -> CutEngine:
|
|
496
|
+
from .serialization import job_from_json
|
|
497
|
+
return cls._from_job(job_from_json(text))
|
|
498
|
+
|
|
499
|
+
def to_xml(self) -> str:
|
|
500
|
+
from .serialization import job_to_xml
|
|
501
|
+
return job_to_xml(self.stocks, self.parts, self.options, self.materials)
|
|
502
|
+
|
|
503
|
+
@classmethod
|
|
504
|
+
def from_xml(cls, text: str) -> CutEngine:
|
|
505
|
+
from .serialization import job_from_xml
|
|
506
|
+
return cls._from_job(job_from_xml(text))
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def _from_job(cls, job) -> CutEngine:
|
|
510
|
+
stocks, parts, options, materials = job
|
|
511
|
+
engine = cls(options)
|
|
512
|
+
engine._stocks, engine._parts, engine._materials = list(stocks), list(parts), list(materials)
|
|
513
|
+
return engine
|
|
514
|
+
|
|
515
|
+
def AddPart(self, width, height, count=1, rotatable=True, id="", material=-1):
|
|
516
|
+
if type(count) is bool:
|
|
517
|
+
count, rotatable = 1, count
|
|
518
|
+
return self.add_part(width, height, count, rotatable, id, material)
|
|
519
|
+
|
|
520
|
+
def AddLinearPart(self, length, count=1, *args):
|
|
521
|
+
if args and isinstance(args[0], str):
|
|
522
|
+
if len(args) > 2:
|
|
523
|
+
raise TypeError("AddLinearPart 参数过多")
|
|
524
|
+
return self.add_linear_part(length, count, id=args[0], material=args[1] if len(args) > 1 else -1)
|
|
525
|
+
if args:
|
|
526
|
+
if not 2 <= len(args) <= 4:
|
|
527
|
+
raise TypeError("斜角重载需要起始角和结束角")
|
|
528
|
+
return self.add_linear_part(length, count, angle_start=args[0], angle_end=args[1],
|
|
529
|
+
id=args[2] if len(args) > 2 else "", material=args[3] if len(args) > 3 else -1)
|
|
530
|
+
return self.add_linear_part(length, count)
|
|
531
|
+
|
|
532
|
+
def AddLinearStock(self, length, count=1, *args):
|
|
533
|
+
if len(args) == 1 and type(args[0]) is int:
|
|
534
|
+
return self.add_linear_stock(length, count, material=args[0])
|
|
535
|
+
if len(args) > 3:
|
|
536
|
+
raise TypeError("AddLinearStock 参数过多")
|
|
537
|
+
return self.add_linear_stock(length, count, id=args[0] if args else "",
|
|
538
|
+
waste=args[1] if len(args) > 1 else False, material=args[2] if len(args) > 2 else -1)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# 旧接口名称集中声明,避免维护两套计算和查询逻辑。
|
|
542
|
+
_METHOD_ALIASES = {
|
|
543
|
+
"Clear": "clear", "AddStock": "add_stock", "AddMaterial": "add_material",
|
|
544
|
+
"GetMaterial": "get_material", "Execute": "execute", "ExecuteLinear": "execute_linear",
|
|
545
|
+
"SetVerticalCutDirection": "set_vertical_cut_direction", "SetHorizontalCutDirection": "set_horizontal_cut_direction",
|
|
546
|
+
"SetAutoCutDirection": "set_auto_cut_direction", "ExportToCSV": "export_csv", "ExportToDXF": "export_dxf",
|
|
547
|
+
"ExportCutsToDXF": "export_cuts_dxf", "ExportToPTX": "export_ptx",
|
|
548
|
+
"CreateStockImage": "create_stock_image", "CreateLinearStockImage": "create_linear_stock_image",
|
|
549
|
+
"ExportToCSV_Stream": "export_csv", "ExportToDXF_Stream": "export_dxf",
|
|
550
|
+
"ExportCutsToDXF_Stream": "export_cuts_dxf", "ExportToPTX_Stream": "export_ptx",
|
|
551
|
+
"CreateStockImage_Stream": "create_stock_image", "CreateLinearStockImage_Stream": "create_linear_stock_image",
|
|
552
|
+
}
|
|
553
|
+
for _name, _value in tuple(vars(CutEngine).items()):
|
|
554
|
+
if _name.startswith("get_") or isinstance(_value, property):
|
|
555
|
+
_METHOD_ALIASES.setdefault(_pascal(_name), _name)
|
|
556
|
+
for _alias, _original in _METHOD_ALIASES.items():
|
|
557
|
+
setattr(CutEngine, _alias, vars(CutEngine)[_original])
|
|
558
|
+
CutEngine.Version = CutEngine.version
|