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,659 @@
1
+ """手册兼容入口:保留 bool/out 和错误字符串,计算仍由纯 Python 内核完成。
2
+
3
+ 原生 CutEngine 返回结构化模型;需要按 C# 手册迁移调用时使用 CutGLibEngine。
4
+ Out 是 Python 对 ref/out 的显式表示,不加载 CLR,也不伪造旧产品许可证。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+ from dataclasses import dataclass, replace
11
+ from math import isfinite, radians, tan
12
+
13
+ from .engine import CutEngine, _OPTION_NAMES, _layout_key
14
+ from .models import Layout, Material, Options, Part, Result, Stock, UnsupportedFeatureError, ValidationError
15
+
16
+
17
+ @dataclass
18
+ class Out:
19
+ value: object = None
20
+
21
+ def __getitem__(self, index):
22
+ if index != 0:
23
+ raise IndexError(index)
24
+ return self.value
25
+
26
+ def __setitem__(self, index, value):
27
+ if index != 0:
28
+ raise IndexError(index)
29
+ self.value = value
30
+
31
+
32
+ class QueryResult(tuple):
33
+ """未提供 Out 时返回 (成功标志, 输出值...);布尔判断使用成功标志。"""
34
+
35
+ def __new__(cls, success, values):
36
+ return super().__new__(cls, (bool(success), *values))
37
+
38
+ def __bool__(self):
39
+ return self[0]
40
+
41
+
42
+ def _answer(outputs, success, values):
43
+ if outputs:
44
+ if len(outputs) != len(values) or any(not isinstance(out, Out) for out in outputs):
45
+ raise TypeError("out 参数必须逐一传入 Out(),数量需与所选重载相同")
46
+ for out, value in zip(outputs, values):
47
+ out.value = value
48
+ return bool(success)
49
+ return QueryResult(success, values)
50
+
51
+
52
+ # 输入参数个数、每种输出重载的默认值。输出顺序与手册/实测签名一致。
53
+ _QUERIES = {
54
+ "GetMaterial": (1, {3: ("", "", 0.0)}),
55
+ "GetLayoutInfo": (1, {2: (0, 0)}),
56
+ "GetStockInfo": (1, {3: (0.0, 0.0, False), 4: (0.0, 0.0, False, "")}),
57
+ "GetLinearStockInfo": (1, {2: (0.0, False), 3: (0.0, False, "")}),
58
+ "GetLinearCombineStockInfo": (2, {3: (0.0, False, "")}),
59
+ "GetResultPart": (1, {6: (-1, 0.0, 0.0, 0.0, 0.0, False), 7: (-1, 0.0, 0.0, 0.0, 0.0, False, "")}),
60
+ "GetResultLinearPart": (1, {3: (-1, 0.0, 0.0), 4: (-1, 0.0, 0.0, ""),
61
+ 6: (-1, 0.0, 0.0, 0.0, 0.0, False), 7: (-1, 0.0, 0.0, 0.0, 0.0, False, False),
62
+ 8: (-1, 0.0, 0.0, 0.0, 0.0, False, False, "")}),
63
+ "GetRemainingPart": (1, {5: (-1, 0.0, 0.0, 0.0, 0.0)}),
64
+ "GetRemainingLinearPart": (1, {3: (-1, 0.0, 0.0), 4: (-1, 0.0, 0.0, 0.0)}),
65
+ "GetCut": (1, {5: (0, 0.0, 0.0, 0.0, 0.0), 6: (0, 0.0, 0.0, 0.0, 0.0, 0)}),
66
+ "GetPartCut": (2, {5: (-1, 0.0, 0.0, 0.0, 0.0)}),
67
+ "GetStockCut": (2, {4: (0.0, 0.0, 0.0, 0.0), 5: (0.0, 0.0, 0.0, 0.0, 0)}),
68
+ "GetStockTrimCut": (2, {4: (0.0, 0.0, 0.0, 0.0), 5: (0.0, 0.0, 0.0, 0.0, 0)}),
69
+ "GetLinearCut": (1, {2: (0, 0.0), 3: (0, 0.0, 0.0)}),
70
+ "GetLinearStockCut": (2, {1: (0.0,), 2: (0.0, 0.0)}),
71
+ }
72
+
73
+
74
+ class CutGLibEngine(CutEngine):
75
+ """手册中的大小写名称采用兼容契约;snake_case 方法保留 Python 原生契约。"""
76
+
77
+ reference_version = "5.3.8"
78
+
79
+ def __init__(self, options=None, **settings):
80
+ super().__init__(options)
81
+ self._inventory_cache = None
82
+ for name, value in settings.items():
83
+ if name in _OPTION_NAMES:
84
+ setattr(self, name, value)
85
+ elif name in Options.__dataclass_fields__:
86
+ setattr(self.options, name, value)
87
+ else:
88
+ raise TypeError(f"未知计算参数:{name}")
89
+
90
+ def __setattr__(self, name, value):
91
+ if name == "MaxDecimalPoint":
92
+ if type(value) is not int:
93
+ raise TypeError("MaxDecimalPoint 必须是整数")
94
+ value = min(6, max(0, value))
95
+ super().__setattr__(name, value)
96
+
97
+ def _modify(self, action):
98
+ value = super()._modify(action)
99
+ self._inventory_cache = None
100
+ return value
101
+
102
+ @staticmethod
103
+ def _number(value):
104
+ if isinstance(value, (str, bytes, bool)):
105
+ raise TypeError("尺寸必须是数值")
106
+ value = float(value)
107
+ return value
108
+
109
+ def _append(self, record):
110
+ # 原 DLL 的 Add 阶段接受负尺寸;执行阶段再报告尺寸错误,原生入口仍严格校验。
111
+ if not isfinite(record.width) or not isfinite(record.height):
112
+ return False
113
+ if abs(record.width) <= 1e-12 or abs(record.height) <= 1e-12:
114
+ return False
115
+ if type(record.count) is not int or type(record.material) is not int:
116
+ raise TypeError("数量与材料索引必须是整数")
117
+ target = self._parts if isinstance(record, Part) else self._stocks
118
+ self._modify(lambda: target.append(record))
119
+ return True
120
+
121
+ def AddPart(self, width, height, count=1, rotatable=True, id="", material=-1):
122
+ if type(count) is bool:
123
+ count, rotatable = 1, count
124
+ return self._append(Part(self._number(width), self._number(height), max(0, count), rotatable, id, material))
125
+
126
+ def AddStock(self, width, height, count=1, id="", waste=False, material=-1):
127
+ return self._append(Stock(self._number(width), self._number(height), max(0, count), id, waste, material))
128
+
129
+ def AddLinearPart(self, length, count=1, *args):
130
+ if args and isinstance(args[0], str):
131
+ if len(args) > 2:
132
+ raise TypeError("AddLinearPart 参数过多")
133
+ id, material = args[0], args[1] if len(args) == 2 else -1
134
+ start = end = 0.0
135
+ elif args:
136
+ if not 2 <= len(args) <= 4:
137
+ raise TypeError("斜角重载需要起始角和结束角")
138
+ start, end = map(self._number, args[:2])
139
+ id, material = args[2] if len(args) > 2 else "", args[3] if len(args) > 3 else -1
140
+ else:
141
+ id, material, start, end = "", -1, 0.0, 0.0
142
+ return self._append(Part(self._number(length), 1.0, max(0, count), False, id, material, start, end))
143
+
144
+ def AddLinearStock(self, length, count=1, *args):
145
+ if len(args) == 1 and type(args[0]) is int:
146
+ id, waste, material = "", False, args[0]
147
+ elif len(args) <= 3:
148
+ id, waste, material = (args[0] if args else "", args[1] if len(args) > 1 else False,
149
+ args[2] if len(args) > 2 else -1)
150
+ else:
151
+ raise TypeError("AddLinearStock 参数过多")
152
+ return self._append(Stock(self._number(length), 1.0, max(0, count), id, waste, material))
153
+
154
+ def AddMaterial(self, id, description, thickness):
155
+ thickness = self._number(thickness)
156
+ if not isfinite(thickness):
157
+ raise ValidationError("材质厚度必须有限")
158
+ def add():
159
+ self._materials.append(Material(id, description, thickness))
160
+ return len(self._materials) - 1
161
+ return self._modify(add)
162
+
163
+ def Clear(self):
164
+ # 原 5.3.8 Clear 实测保留三个搜索开关和尺寸精度,其余恢复默认。
165
+ retained = {name: getattr(self.options, name) for name in
166
+ ("linear_allow_flipping", "linear_sort_ascending", "use_large_stock_first", "max_decimal_point")}
167
+ self.clear(reset_options=True)
168
+ for name, value in retained.items():
169
+ setattr(self.options, name, value)
170
+
171
+ def _run_documented(self, linear, callback):
172
+ self._inventory_cache = None
173
+ if not self.part_count:
174
+ return "Number of parts must be more than zero"
175
+ if not self.stocks:
176
+ return "Length of stock 0 is negative (0). Please check trims and saw kerf values to be less then the stock length."
177
+ def progress(_):
178
+ stop = Out(False)
179
+ returned = callback(stop)
180
+ return bool(stop.value) or returned is True
181
+ try:
182
+ result = self.execute_linear(progress if callback else None) if linear else self.execute(progress if callback else None)
183
+ except ValidationError as exc:
184
+ if "无法" in str(exc) or "cannot" in str(exc):
185
+ # 公开错误字符串与原版一致;异常输入仍可通过原生接口获得详细中文错误。
186
+ for part in self.parts:
187
+ return f"Part {part.id} cannot be placed because it exceeds the stock size"
188
+ return str(exc)
189
+ if result.cancelled:
190
+ return "Calculation stopped by the user"
191
+ if result.timed_out:
192
+ return "Calculation time limit reached"
193
+ return ""
194
+
195
+ def Execute(self, callback=None):
196
+ return self._run_documented(False, callback)
197
+
198
+ def ExecuteLinear(self, callback=None):
199
+ return self._run_documented(True, callback)
200
+
201
+ def _execute(self, linear, callback, *, solver=None):
202
+ self._inventory_cache = None
203
+ return super()._execute(linear, callback, solver=solver)
204
+
205
+ def _inventory(self):
206
+ """按原版材料/尺寸排序,批次保存未用库存,避免展开 20000 根无限原料。"""
207
+ if self._inventory_cache is not None:
208
+ return self._inventory_cache
209
+ if self._result is None:
210
+ return (), {}, 0
211
+ consumed = Counter()
212
+ active = {}
213
+ combined = []
214
+ for index, layout in enumerate(self.result.layouts):
215
+ components = layout.components
216
+ if len(components) > 1:
217
+ consumed.update(c.input_index for c in components)
218
+ ordered = sorted(components, key=lambda c: (c.width, c.input_index))
219
+ id = ";".join(self._component_id(c) for c in ordered)
220
+ combined.append((layout.material, layout.width, 1.0 if self.result.linear else layout.height, layout.waste,
221
+ id, 1, [index], None))
222
+ else:
223
+ consumed[layout.input_index] += 1
224
+ active.setdefault(layout.input_index, []).append(index)
225
+ batches = list(combined)
226
+ for i, stock in enumerate(self.stocks):
227
+ layouts = active.get(i, [])
228
+ unused = (stock.count or 20000) - consumed[i]
229
+ count = max(0, unused) + len(layouts)
230
+ if count:
231
+ batches.append((stock.material, stock.width, stock.height, stock.waste, stock.id, count, layouts, i))
232
+ batches.sort(key=lambda b: (-b[0], -b[1] * b[2], b[1], -b[3]))
233
+ segments, mapping, start = [], {}, 0
234
+ for batch in batches:
235
+ segments.append((start, batch))
236
+ mapping.update((local, start + offset) for offset, local in enumerate(batch[6]))
237
+ start += batch[5]
238
+ self._inventory_cache = tuple(segments), mapping, start
239
+ return self._inventory_cache
240
+
241
+ def _component_id(self, component):
242
+ if component.id:
243
+ return component.id
244
+ return str(sum(stock.count or 20000 for stock in self.stocks[:component.input_index])
245
+ + component.instance_index)
246
+
247
+ def _stock_id(self, stock, batch, local):
248
+ if batch[4] or batch[7] is None:
249
+ return batch[4]
250
+ input_index = batch[7]
251
+ base = sum(item.count or 20000 for item in self.stocks[:input_index])
252
+ if local is not None:
253
+ return str(base + self.result.layouts[local].instance_index)
254
+ start = next(start for start, candidate in self._inventory()[0] if candidate is batch)
255
+ offset = stock - start - len(batch[6])
256
+ used = {component.instance_index for layout in self.result.layouts for component in layout.components
257
+ if component.input_index == input_index}
258
+ used.update(layout.instance_index for layout in self.result.layouts
259
+ if layout.input_index == input_index and len(layout.components) <= 1)
260
+ instance = 0
261
+ while instance in used or offset:
262
+ if instance not in used:
263
+ offset -= 1
264
+ instance += 1
265
+ return str(base + instance)
266
+
267
+ def _stock(self, index):
268
+ segments, _, total = self._inventory()
269
+ if self._result is None:
270
+ return None
271
+ if index < 0:
272
+ raise IndexError(index)
273
+ if index >= total:
274
+ return None
275
+ for start, batch in reversed(segments):
276
+ if index >= start:
277
+ offset = index - start
278
+ local = batch[6][offset] if offset < len(batch[6]) else None
279
+ return batch, local
280
+ return None
281
+
282
+ def _located(self, index):
283
+ if self._result is None or not 0 <= index < self.part_count:
284
+ return None
285
+ located = self.get_result_part(index)
286
+ return None if located is None else (self._inventory()[1][located[0]], located[1])
287
+
288
+ def _unplaced_values(self, index, name, n, flags):
289
+ # 原映射越界变为 -1,manager 再把 -1 映射至内部排序后的首件。
290
+ # 首件以已放置优先、原料序号、y、x 排序,不依赖特定订单或 ID。
291
+ locations = self._part_locations()
292
+ if 0 <= index < len(locations):
293
+ offset = index
294
+ for record in self.parts:
295
+ if offset < record.count:
296
+ part = record
297
+ width, height = record.width, record.height
298
+ break
299
+ offset -= record.count
300
+ else:
301
+ placed = [(self._inventory()[1][value[0]], value[1].y, value[1].x, value[1])
302
+ for value in locations if value is not None]
303
+ if placed:
304
+ placement = min(placed, key=lambda value: value[:3])[3]
305
+ part = self.parts[placement.input_index]
306
+ width, height = placement.width, placement.height
307
+ if self.result.linear:
308
+ height = part.height
309
+ else:
310
+ candidates = [(record.rotatable, -max(record.width, record.height)
311
+ if record.rotatable else -record.width,
312
+ -min(record.width, record.height) if record.rotatable else -record.height,
313
+ -i, record) for i, record in enumerate(self.parts) if record.count]
314
+ if not candidates:
315
+ return None
316
+ part = min(candidates, key=lambda value: value[:4])[4]
317
+ width, height = part.width, part.height
318
+ if part.rotatable and height > width:
319
+ width, height = height, width
320
+ if name == "GetResultPart":
321
+ return (-1, width, height, 0.0, 0.0, part.rotatable, part.id)[:n]
322
+ if n <= 4:
323
+ return (-1, width, 0.0, part.id)[:n]
324
+ values = (-1, width, part.angle_start, part.angle_end, 0.0, False)
325
+ return (*values, part.id) if n == 7 and flags.get("pdf_id") else (*values, False, part.id)[:n]
326
+
327
+ def _cut_values(self, cut, layout):
328
+ """匹配原结果缓冲区:直切是线段,型材斜切存 position/angle。"""
329
+ if self.result.linear:
330
+ if self._result_options.cross_section > 0:
331
+ angle = cut.angle
332
+ if angle == 90 and all(part.angle_start != 90 and part.angle_end != 90
333
+ for part in layout.parts):
334
+ angle = 0.0
335
+ position = max(cut.x1, cut.x2) if angle > 90 else min(cut.x1, cut.x2)
336
+ return (position, angle, 0.0, 0.0, 0)
337
+ return (cut.x1, cut.y1, cut.x2, cut.y2, -1)
338
+ return (cut.x1, cut.y1, cut.x2, cut.y2, cut.level)
339
+
340
+ def _query(self, name, inputs, n, defaults, flags):
341
+ empty = (False, defaults)
342
+ if name == "GetMaterial":
343
+ index = inputs[0]
344
+ if index < 0:
345
+ raise IndexError(index)
346
+ if index >= len(self.materials):
347
+ return empty
348
+ m = self.materials[index]
349
+ return True, (m.id, m.description, m.thickness)
350
+ if self._result is None:
351
+ # 该返回值已经由原 DLL 所有执行前重载逐项测量。
352
+ return empty
353
+ mapping = self._inventory()[1]
354
+ if name in ("GetStockInfo", "GetLinearStockInfo"):
355
+ entry = self._stock(inputs[0])
356
+ if entry is None:
357
+ return empty
358
+ batch, local = entry
359
+ if local is not None:
360
+ layout = self.result.layouts[local]
361
+ width, height = layout.width, batch[2] if self.result.linear else layout.height
362
+ else:
363
+ width, height = batch[1:3]
364
+ id = self._stock_id(inputs[0], batch, local)
365
+ values = (width, height, local is not None, id) if name == "GetStockInfo" else (width, local is not None, id)
366
+ return True, values[:n]
367
+ if name == "GetLinearCombineStockInfo":
368
+ # 原方法先检查组件下标与全部库存数,再访问组合原料下标。
369
+ if inputs[1] >= self.StockCount:
370
+ return empty
371
+ if inputs[0] < 0 or inputs[0] >= self.StockCount:
372
+ raise IndexError(inputs[0])
373
+ entry = self._stock(inputs[0])
374
+ if entry is None or entry[1] is None:
375
+ return empty
376
+ components = sorted(self.result.layouts[entry[1]].components, key=lambda c: (c.width, c.input_index))
377
+ if len(components) > 1 and inputs[1] < 0:
378
+ raise IndexError(inputs[1])
379
+ if len(components) <= 1 or not 0 <= inputs[1] < len(components):
380
+ return empty
381
+ c = components[inputs[1]]
382
+ return True, (c.width, True, self._component_id(c))
383
+ if name == "GetLayoutInfo":
384
+ if not 0 <= inputs[0] < self.layout_count:
385
+ return empty
386
+ group = self.result.layout_groups[inputs[0]]
387
+ return True, (mapping[group.first_layout_index], group.layout_count)
388
+ if name in ("GetResultPart", "GetResultLinearPart"):
389
+ located = self._located(inputs[0])
390
+ if located is None:
391
+ values = self._unplaced_values(inputs[0], name, n, flags)
392
+ return (False, values) if values else empty
393
+ stock, p = located
394
+ if name == "GetResultPart":
395
+ # 原 GetResultPart 返回输入宽高,旋转由 aRotated 单独表示。
396
+ w, h = (p.height, p.width) if p.rotated and not self.result.linear else (p.width, p.height)
397
+ if self.result.linear:
398
+ h = self.parts[p.input_index].height
399
+ return True, (stock, w, h, p.x, p.y, p.rotated, p.id)[:n]
400
+ if n <= 4:
401
+ return True, (stock, p.width, p.x, p.id)[:n]
402
+ values = (stock, p.width, p.angle_start, p.angle_end, p.x, p.rotated)
403
+ if n == 7 and flags.get("pdf_id"):
404
+ return True, (*values, p.id)
405
+ return True, (*values, p.flipped, p.id)[:n]
406
+ if name in ("GetRemainingPart", "GetRemainingLinearPart"):
407
+ remnants = self._remnants()
408
+ if not 0 <= inputs[0] < len(remnants):
409
+ if inputs[0] < 0:
410
+ raise IndexError(inputs[0])
411
+ return empty
412
+ stock, r = remnants[inputs[0]]
413
+ width, x, angle = r.width, r.x, r.angle
414
+ if self.result.linear and self._result_options.cross_section > 0:
415
+ parts = self.result.layouts[stock].parts
416
+ if parts:
417
+ last = max(parts, key=lambda part: part.x + part.width)
418
+ if last.angle_end % 90:
419
+ overlap = abs(self._result_options.cross_section / tan(radians(last.angle_end)))
420
+ width, x = width + overlap, x - overlap
421
+ angle = last.angle_end
422
+ values = ((mapping[stock], width, 1.0 if self.result.linear else r.height, x, r.y) if name == "GetRemainingPart"
423
+ else (mapping[stock], width, x, angle))
424
+ return True, values[:n]
425
+ if name in ("GetCut", "GetLinearCut"):
426
+ cuts = self._cuts()
427
+ if not 0 <= inputs[0] < len(cuts):
428
+ return empty
429
+ stock, cut = cuts[inputs[0]]
430
+ segment = self._cut_values(cut, self.result.layouts[stock])
431
+ values = ((mapping[stock], *segment[:2]) if name == "GetLinearCut" else
432
+ (mapping[stock], *segment))
433
+ return True, values[:n]
434
+ if name == "GetPartCut":
435
+ if not 0 <= inputs[0] < self.part_count:
436
+ return empty
437
+ if self.result.linear:
438
+ # 原版此路径空引用异常;纯 Python 保留安全的 false,差异报告单独列出。
439
+ return empty
440
+ stock, cuts = self._part_cuts(inputs[0])
441
+ if inputs[1] < 0 and cuts:
442
+ raise IndexError(inputs[1])
443
+ if not 0 <= inputs[1] < len(cuts):
444
+ return empty
445
+ c = cuts[inputs[1]]
446
+ return True, (mapping[stock], c.x1, c.y1, c.x2, c.y2)
447
+ if inputs[0] < 0:
448
+ return empty
449
+ entry = self._stock(inputs[0])
450
+ if entry is None or entry[1] is None:
451
+ return empty
452
+ layout = self.result.layouts[entry[1]]
453
+ cuts = layout.trim_cuts if name == "GetStockTrimCut" else layout.cuts
454
+ if inputs[1] < 0 and cuts:
455
+ raise IndexError(inputs[1])
456
+ if not 0 <= inputs[1] < len(cuts):
457
+ return empty
458
+ c = cuts[inputs[1]]
459
+ segment = self._cut_values(c, layout)
460
+ values = segment[:2] if name == "GetLinearStockCut" else segment
461
+ return True, values[:n]
462
+
463
+ @property
464
+ def StockCount(self):
465
+ return self._inventory()[2]
466
+
467
+ LinearStockCount = StockCount
468
+
469
+ @property
470
+ def UsedStockCount(self):
471
+ return len(self._result.layouts) if self._result else 0
472
+
473
+ UsedLinearStockCount = UsedStockCount
474
+
475
+ @property
476
+ def RemainingPartCount(self):
477
+ return self.remaining_part_count if self._result is not None else 0
478
+
479
+ @property
480
+ def LayoutCount(self):
481
+ return self.layout_count if self._result is not None else 0
482
+
483
+ @property
484
+ def PlacedPartCount(self):
485
+ return self.placed_part_count if self._result is not None else 0
486
+
487
+ @property
488
+ def ElapsedTime(self):
489
+ return self.elapsed_time if self._result is not None else 0.0
490
+
491
+ def GetPartMaterial(self, part):
492
+ if self._result is None or not 0 <= part < self.part_count:
493
+ return -1
494
+ return self.get_part_material(part)
495
+
496
+ def GetStockMaterial(self, stock):
497
+ entry = self._stock(stock)
498
+ return entry[0][0] if entry else -1
499
+
500
+ def GetRemainingPartMaterial(self, part):
501
+ if self._result is None:
502
+ return -1
503
+ if not 0 <= part < self.remaining_part_count:
504
+ raise IndexError(part)
505
+ return self.get_remaining_part_material(part)
506
+
507
+ def GetLayoutFillRatio(self, layout):
508
+ return self.get_layout_fill_ratio(layout) if self._result and 0 <= layout < self.layout_count else 0.0
509
+
510
+ def GetPartCountOnStock(self, stock):
511
+ if stock < 0:
512
+ return 0
513
+ entry = self._stock(stock)
514
+ return len(self.result.layouts[entry[1]].parts) if entry and entry[1] is not None else 0
515
+
516
+ def GetPartIndexOnStock(self, stock, part):
517
+ if stock < 0:
518
+ return -1
519
+ entry = self._stock(stock)
520
+ if not entry or entry[1] is None or not 0 <= part < len(self.result.layouts[entry[1]].parts):
521
+ return -1
522
+ return self.get_part_index_on_stock(entry[1], part)
523
+
524
+ def GetRemainingPartCountOnStock(self, stock):
525
+ if stock < 0:
526
+ return 0
527
+ entry = self._stock(stock)
528
+ return len(self.result.layouts[entry[1]].all_remnants) if entry and entry[1] is not None else 0
529
+
530
+ def GetRemainingPartIndexOnStock(self, stock, part):
531
+ if stock < 0:
532
+ return -1
533
+ entry = self._stock(stock)
534
+ if not entry or entry[1] is None or not 0 <= part < len(self.result.layouts[entry[1]].all_remnants):
535
+ return -1
536
+ return self.get_remaining_part_index_on_stock(entry[1], part)
537
+
538
+ def GetLinearCombineStockCount(self, stock):
539
+ entry = self._stock(stock)
540
+ return self.get_linear_combine_stock_count(entry[1]) if entry and entry[1] is not None else 0
541
+
542
+ def GetPartCutsCount(self, part):
543
+ return self.get_part_cuts_count(part) if self._result and not self.result.linear and 0 <= part < self.part_count else 0
544
+
545
+ def GetCutsCount(self):
546
+ return self.get_cuts_count() if self._result else 0
547
+
548
+ GetLinearCutsCount = GetCutsCount
549
+
550
+ def GetStockCutCount(self, stock):
551
+ if stock < 0:
552
+ return 0
553
+ entry = self._stock(stock)
554
+ return self.get_stock_cut_count(entry[1]) if entry and entry[1] is not None else 0
555
+
556
+ def GetStockTrimCutCount(self, stock):
557
+ if stock < 0:
558
+ return 0
559
+ entry = self._stock(stock)
560
+ return self.get_stock_trim_cut_count(entry[1]) if entry and entry[1] is not None else 0
561
+
562
+ def _export(self, method, target, *args, **kwargs):
563
+ if method == "export_ptx" and self._result and self.result.linear:
564
+ return False
565
+ # 文件系统/不可写流异常按原版传播,不把 IO 错误伪装成 false。
566
+ if self._result is None:
567
+ return False
568
+ if target is None:
569
+ raise TypeError("导出目标不能为 None")
570
+ getattr(self, method)(target, *args, **kwargs)
571
+ return True
572
+
573
+ def ExportToCSV(self, target):
574
+ return self._export("export_csv", target)
575
+
576
+ ExportToCSV_Stream = ExportToCSV
577
+
578
+ def ExportToDXF(self, target, include_part_ids):
579
+ return self._export("export_dxf", target, include_part_ids)
580
+
581
+ ExportToDXF_Stream = ExportToDXF
582
+
583
+ def ExportCutsToDXF(self, target, include_cut_number, include_stock_panel):
584
+ return self._export("export_cuts_dxf", target, include_cut_number, include_stock_panel)
585
+
586
+ ExportCutsToDXF_Stream = ExportCutsToDXF
587
+
588
+ def ExportToPTX(self, target, job, customer, material, thickness, units, scale, require_all_trim_cuts=False):
589
+ return self._export("export_ptx", target, job, customer, material, thickness, units, scale, require_all_trim_cuts)
590
+
591
+ def ExportToPTX_Stream(self, target, *args):
592
+ if len(args) == 6:
593
+ # 原七参数流重载的顺序与文件重载不同,默认保留全部修边。
594
+ job, material, thickness, customer, units, scale = args
595
+ return self.ExportToPTX(target, job, customer, material, thickness, units, scale, True)
596
+ if len(args) == 7:
597
+ return self.ExportToPTX(target, *args)
598
+ raise TypeError("ExportToPTX_Stream 需要六个或七个任务参数")
599
+
600
+ def _create_image(self, stock, target, max_size, linear, stream=False):
601
+ if not stream and not hasattr(target, "write"):
602
+ # 原文件入口先创建文件,再查询原料;无结果时也会留下空文件。
603
+ with open(target, "wb") as destination:
604
+ return self._create_image(stock, destination, max_size, linear, stream=True)
605
+ entry = self._stock(stock)
606
+ if entry is None:
607
+ return False
608
+ if target is None:
609
+ raise TypeError("图片输出流不能为 None")
610
+ batch, local = entry
611
+ layout = self.result.layouts[local] if local is not None else Layout(
612
+ -1, stock, batch[1], batch[2], batch[4], batch[0], batch[3])
613
+ if self.result.linear:
614
+ layout = replace(layout, height=batch[2])
615
+ from .exporters import render_png
616
+ target.write(render_png(layout, linear=linear, max_size=min(4000, max(50, max_size))))
617
+ return True
618
+
619
+ def CreateStockImage(self, stock, target, max_size=1000):
620
+ return self._create_image(stock, target, max_size, False)
621
+
622
+ def CreateStockImage_Stream(self, stock, target, max_size=1000):
623
+ return self._create_image(stock, target, max_size, False, stream=True)
624
+
625
+ def CreateLinearStockImage(self, stock, target, max_size=1000):
626
+ return self._create_image(stock, target, max_size, True)
627
+
628
+ def CreateLinearStockImage_Stream(self, stock, target, max_size=1000):
629
+ return self._create_image(stock, target, max_size, True, stream=True)
630
+
631
+
632
+ def _query_method(name, input_count, variants):
633
+ def method(self, *args, include_id=False, include_angles=False, include_angle=False,
634
+ with_level=False, include_flipped=True):
635
+ if len(args) < input_count:
636
+ raise TypeError(f"{name} 缺少输入参数")
637
+ inputs, outputs = args[:input_count], args[input_count:]
638
+ n = len(outputs) if outputs else min(variants)
639
+ if not outputs:
640
+ if name == "GetResultLinearPart" and include_angles:
641
+ n = (7 if include_flipped else 6) + bool(include_id)
642
+ elif include_id or include_angle or with_level:
643
+ n += 1
644
+ if n not in variants:
645
+ raise TypeError(f"{name} 不支持 {n} 个 out 参数")
646
+ flags = {"pdf_id": name == "GetResultLinearPart" and n == 7 and
647
+ ((outputs and isinstance(outputs[-1].value, str)) or not include_flipped)}
648
+ defaults = variants[n]
649
+ if flags["pdf_id"]:
650
+ defaults = (*defaults[:-1], "")
651
+ success, values = self._query(name, inputs, n, defaults, flags)
652
+ return _answer(outputs, success, values)
653
+ method.__name__ = name
654
+ method.__doc__ = f"手册 {name};支持 Out 引用或返回 (成功标志, 输出值...)。"
655
+ return method
656
+
657
+
658
+ for _name, (_inputs, _variants) in _QUERIES.items():
659
+ setattr(CutGLibEngine, _name, _query_method(_name, _inputs, _variants))