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,362 @@
1
+ """纯标准库布局导出。所有导出先构造完整内容,再写入调用方路径或流。"""
2
+
3
+ import csv
4
+ from datetime import date
5
+ from html import escape
6
+ import io
7
+ import math
8
+ from pathlib import Path
9
+ import struct
10
+ import zlib
11
+
12
+ from .models import Options, UnsupportedFeatureError, ValidationError
13
+ from .serialization import _check
14
+
15
+
16
+ def _write(target, text):
17
+ if isinstance(target, (str, bytes, Path)):
18
+ Path(target).write_text(text, encoding="utf-8", newline="")
19
+ elif isinstance(target, (io.BufferedIOBase, io.RawIOBase, io.BytesIO)):
20
+ target.write(text.encode("utf-8"))
21
+ else:
22
+ target.write(text)
23
+
24
+
25
+ def _csv(rows):
26
+ output = io.StringIO(newline="")
27
+ csv.writer(output, lineterminator="\r\n").writerows(rows)
28
+ return output.getvalue()
29
+
30
+
31
+ def _n(value):
32
+ if not math.isfinite(value):
33
+ raise ValidationError("导出坐标必须是有限数值")
34
+ return format(value, ".12g")
35
+
36
+
37
+ def _validate(result):
38
+ from dataclasses import asdict
39
+ _check(asdict(result))
40
+
41
+
42
+ def export_csv(result, target):
43
+ """与 CutGLib 一致的七列布局 CSV,UTF-8,正确转义引号和换行。"""
44
+ _validate(result)
45
+ rows = [[" Sheet", "ID", "X", "Y", "Width", "Height", "Rotated"]]
46
+ for i, layout in enumerate(result.layouts, 1):
47
+ for p in layout.parts:
48
+ rows.append([layout.id or str(i), p.id, _n(p.x), _n(p.y),
49
+ _n(p.width), _n(p.height), "yes" if p.rotated else "no"])
50
+ _write(target, _csv(rows))
51
+
52
+
53
+ def _polygon(part):
54
+ # polygon 中的点是布局绝对坐标。
55
+ return part.polygon or ((part.x, part.y), (part.x + part.width, part.y),
56
+ (part.x + part.width, part.y + part.height),
57
+ (part.x, part.y + part.height))
58
+
59
+
60
+ def export_dxf(result, target, include_part_ids=True, cuts_only=False,
61
+ include_cut_number=True, include_stock_panel=True):
62
+ """输出 ASCII DXF R12,按 Panel_N 分层;中文保留安全 Unicode 转义。"""
63
+ _validate(result)
64
+ pairs = []
65
+ def pair(code, value):
66
+ pairs.extend((str(code), str(value)))
67
+ def text_value(value):
68
+ # 每个非 ASCII UTF-16 单元编码为 DXF \U+XXXX,避免换行注入组码。
69
+ value = str(value).replace("\\", "\\\\").replace("\r", " ").replace("\n", " ")
70
+ out = []
71
+ for c in value:
72
+ if ord(c) < 128:
73
+ out.append(c)
74
+ else:
75
+ raw = c.encode("utf-16-be")
76
+ out.extend("\\U+" + raw[i:i+2].hex().upper() for i in range(0, len(raw), 2))
77
+ return "".join(out)
78
+ def line(layer, x1, y1, x2, y2):
79
+ for c, v in ((0,"LINE"),(8,layer),(10,_n(x1)),(20,_n(y1)),(30,0),
80
+ (11,_n(x2)),(21,_n(y2)),(31,0)):
81
+ pair(c,v)
82
+ def polygon(layer, points):
83
+ for a, b in zip(points, points[1:] + points[:1]):
84
+ line(layer,*a,*b)
85
+ def label(layer,x,y,value,height):
86
+ for c,v in ((0,"TEXT"),(8,layer),(10,_n(x)),(20,_n(y)),(30,0),
87
+ (40,_n(height)),(1,text_value(value))):
88
+ pair(c,v)
89
+ for c,v in ((0,"SECTION"),(2,"HEADER"),(9,"$ACADVER"),(1,"AC1009"),
90
+ (0,"ENDSEC"),(0,"SECTION"),(2,"TABLES"),(0,"TABLE"),
91
+ (2,"LAYER"),(70,len(result.layouts))):
92
+ pair(c,v)
93
+ for i in range(1,len(result.layouts)+1):
94
+ for c,v in ((0,"LAYER"),(2,f"Panel_{i}"),(70,0),(62,7),(6,"CONTINUOUS")):
95
+ pair(c,v)
96
+ for c,v in ((0,"ENDTAB"),(0,"ENDSEC"),(0,"SECTION"),(2,"ENTITIES")):
97
+ pair(c,v)
98
+ for i,layout in enumerate(result.layouts,1):
99
+ layer = f"Panel_{i}"
100
+ if include_stock_panel:
101
+ polygon(layer, ((0,0),(layout.width,0),(layout.width,layout.height),(0,layout.height)))
102
+ if cuts_only:
103
+ for number,cut in enumerate((*layout.trim_cuts,*layout.cuts),1):
104
+ line(layer,cut.x1,cut.y1,cut.x2,cut.y2)
105
+ if include_cut_number:
106
+ label(layer,(cut.x1+cut.x2)/2,(cut.y1+cut.y2)/2,number,max(layout.height/100,0.01))
107
+ else:
108
+ for p in layout.parts:
109
+ polygon(layer,_polygon(p))
110
+ if include_part_ids:
111
+ label(layer,p.x+p.width/2,p.y+p.height/2,p.id,max(min(p.width,p.height)/10,0.01))
112
+ pair(0,"ENDSEC")
113
+ pair(0,"EOF")
114
+ _write(target,"\r\n".join(pairs)+"\r\n")
115
+
116
+
117
+ def _viewport(layout, max_size, *, png=False, linear=False):
118
+ if type(max_size) is not int or not 1 <= max_size <= 8192:
119
+ raise ValidationError("max_size 必须在 1 到 8192 之间")
120
+ if not all(math.isfinite(v) and v > 0 for v in (layout.width,layout.height)):
121
+ raise ValidationError("布局宽高必须为正有限数值")
122
+ from dataclasses import asdict
123
+ _check(asdict(layout))
124
+ if png:
125
+ # 原 DLL 实测:限制值夹在 50..4000,并为边界另留一像素。
126
+ max_size=min(4000,max(50,max_size))
127
+ aspect=layout.width/(60*layout.width/max_size if linear else layout.height)
128
+ if aspect>1:
129
+ width,height=max_size,math.ceil(max_size/aspect)
130
+ else:
131
+ width,height=math.ceil(max_size*aspect),max_size
132
+ return width+1,height+1,width/layout.width
133
+ scale = max_size / max(layout.width,layout.height)
134
+ return max(1,round(layout.width*scale)), max(1,round(layout.height*scale)), scale
135
+
136
+
137
+ def render_svg(layout, linear=False, max_size=1000):
138
+ """返回具有实际斜角多边形及可检索零件 ID 的独立 SVG。"""
139
+ width,height,scale = _viewport(layout,max_size)
140
+ items = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {_n(layout.width)} {_n(layout.height)}">',
141
+ f'<rect width="{_n(layout.width)}" height="{_n(layout.height)}" fill="#f2f2f2" stroke="#333"/>']
142
+ for p in layout.parts:
143
+ points=" ".join(f"{_n(x)},{_n(y)}" for x,y in _polygon(p))
144
+ items.append(f'<polygon points="{points}" fill="#a9d4ed" stroke="#28546c" stroke-width="{_n(1/scale)}"><title>{escape(p.id)}</title></polygon>')
145
+ items.append(f'<text x="{_n(p.x+p.width/2)}" y="{_n(p.y+p.height/2)}" font-size="{_n(min(p.height/3,12/scale))}" text-anchor="middle">{escape(p.id)}</text>')
146
+ for c in (*layout.trim_cuts,*layout.cuts):
147
+ items.append(f'<line x1="{_n(c.x1)}" y1="{_n(c.y1)}" x2="{_n(c.x2)}" y2="{_n(c.y2)}" stroke="#b22" stroke-width="{_n(1/scale)}"/>')
148
+ return "".join(items)+"</svg>"
149
+
150
+
151
+ def render_png(layout, linear=False, max_size=1000):
152
+ """返回 RGB PNG;扫描线填充实际轮廓,不依赖外部图像库。"""
153
+ width,height,scale = _viewport(layout,max_size,png=True,linear=linear)
154
+ yscale=(height-1)/layout.height if linear else scale
155
+ pixels=bytearray(b"\xf2\xf2\xf2"*(width*height))
156
+ def pixel(x,y,color):
157
+ if 0<=x<width and 0<=y<height:
158
+ at=(y*width+x)*3
159
+ pixels[at:at+3]=color
160
+ def draw_line(x1,y1,x2,y2,color):
161
+ # 先裁剪到画布,极远的有限坐标不能导致逐像素死循环。
162
+ dx,dy=x2-x1,y2-y1
163
+ begin,end=0.,1.
164
+ for p,q in ((-dx,x1),(dx,layout.width-x1),(-dy,y1),(dy,layout.height-y1)):
165
+ if p==0:
166
+ if q<0: return
167
+ continue
168
+ ratio=q/p
169
+ if p<0: begin=max(begin,ratio)
170
+ else: end=min(end,ratio)
171
+ if begin>end: return
172
+ x1,y1,x2,y2=x1+begin*dx,y1+begin*dy,x1+end*dx,y1+end*dy
173
+ x1,y1,x2,y2=(round(x1*scale),round(y1*yscale),round(x2*scale),round(y2*yscale))
174
+ dx,dy=abs(x2-x1),-abs(y2-y1)
175
+ sx,sy=(1 if x1<x2 else -1),(1 if y1<y2 else -1)
176
+ err=dx+dy
177
+ while True:
178
+ pixel(x1,y1,color)
179
+ if x1==x2 and y1==y2: break
180
+ e2=2*err
181
+ if e2>=dy: err+=dy; x1+=sx
182
+ if e2<=dx: err+=dx; y1+=sy
183
+ for p in layout.parts:
184
+ points=_polygon(p)
185
+ scaled=[(x*scale,y*yscale) for x,y in points]
186
+ for y in range(max(0,math.floor(min(pt[1] for pt in scaled))),min(height,math.ceil(max(pt[1] for pt in scaled)))):
187
+ scan=y+0.5
188
+ xs=[]
189
+ for (x1,y1),(x2,y2) in zip(scaled,scaled[1:]+scaled[:1]):
190
+ if min(y1,y2)<=scan<max(y1,y2):
191
+ xs.append(x1+(scan-y1)*(x2-x1)/(y2-y1))
192
+ xs.sort()
193
+ for left,right in zip(xs[::2],xs[1::2]):
194
+ for x in range(max(0,math.ceil(left-0.5)),min(width,math.ceil(right-0.5))):
195
+ pixel(x,y,b"\xa9\xd4\xed")
196
+ for a,b in zip(points,points[1:]+points[:1]): draw_line(*a,*b,b"\x28\x54\x6c")
197
+ for c in (*layout.trim_cuts,*layout.cuts): draw_line(c.x1,c.y1,c.x2,c.y2,b"\xbb\x22\x22")
198
+ def chunk(kind,data):
199
+ return struct.pack(">I",len(data))+kind+data+struct.pack(">I",zlib.crc32(kind+data)&0xffffffff)
200
+ raw=b"".join(b"\0"+bytes(pixels[y*width*3:(y+1)*width*3]) for y in range(height))
201
+ return (b"\x89PNG\r\n\x1a\n"+chunk(b"IHDR",struct.pack(">IIBBBBB",width,height,8,2,0,0,0))+
202
+ chunk(b"IDAT",zlib.compress(raw))+chunk(b"IEND",b""))
203
+
204
+
205
+ def export_ptx(result, target, job_name="", customer="", material="",
206
+ material_thickness=0, units=0, unit_scale=1,
207
+ require_all_trim_cuts=False, options=None, materials=()):
208
+ """输出 PTX 1.08 断头切割记录;拒绝一维及无法断头分割的布局。
209
+
210
+ 按实际零件几何重建切割树,PATTERNS/CUTS 的尺寸为每级相对尺寸,
211
+ 不是全局坐标。每个布局独立模式,避免合并后丢失实例分配。
212
+ """
213
+ _validate(result)
214
+ if result.linear:
215
+ raise UnsupportedFeatureError("PTX 仅支持二维矩形断头切割")
216
+ if not math.isfinite(unit_scale) or unit_scale<=0:
217
+ raise ValidationError("unit_scale 必须是正有限数值")
218
+ if type(units) is not int:
219
+ raise ValidationError("units 必须为整数")
220
+ _check([job_name,customer,material,material_thickness])
221
+ opt=options or Options()
222
+ from dataclasses import asdict
223
+ _check(asdict(opt))
224
+ if opt.saw_width<0:
225
+ raise ValidationError("锯缝必须非负")
226
+ def dim(v): return _n(round(v*unit_scale,2))
227
+ part_keys={}
228
+ part_rows=[]
229
+ stock_rows=[]
230
+ offcut_rows=[]
231
+ patterns=[]
232
+ sequences=[]
233
+ remaining_area=0.
234
+ tol=1e-7
235
+ for pattern,layout in enumerate(result.layouts,1):
236
+ if any(p.polygon or p.angle_start or p.angle_end for p in layout.parts):
237
+ raise UnsupportedFeatureError("PTX 不支持斜角轮廓")
238
+ if layout.components:
239
+ raise UnsupportedFeatureError("PTX 不支持组合原料")
240
+ stock_rows.append(["BOARDS",1,pattern,layout.id,max(1,layout.material+1),dim(layout.width),dim(layout.height),1,1,0,0])
241
+ items=[]
242
+ for p in layout.parts:
243
+ key=(p.id,p.width,p.height,p.material)
244
+ if key not in part_keys:
245
+ part_keys[key]=len(part_rows)+1
246
+ part_rows.append(["PARTS_REQ",1,len(part_rows)+1,p.id,max(1,p.material+1),dim(p.width),dim(p.height),0,0,0,1,0])
247
+ number=part_keys[key]
248
+ part_rows[number-1][7]+=1
249
+ part_rows[number-1][11]+=1
250
+ items.append((p.x,p.y,p.width,p.height,number))
251
+ for i,p in enumerate(layout.parts):
252
+ if p.x < -tol or p.y < -tol or p.x+p.width>layout.width+tol or p.y+p.height>layout.height+tol:
253
+ raise ValidationError("PTX 零件越出原料")
254
+ for other in layout.parts[:i]:
255
+ if min(p.x+p.width,other.x+other.width)>max(p.x,other.x)+tol and min(p.y+p.height,other.y+other.height)>max(p.y,other.y)+tol:
256
+ raise ValidationError("PTX 零件重叠")
257
+ first_axis=1 if opt.cut_direction=="horizontal" else 0
258
+ if layout.cuts:
259
+ c=layout.cuts[0]
260
+ first_axis=0 if abs(c.x1-c.x2)<tol else 1
261
+ # 对齐 PtxLayoutExporter.ExtendRemnantsToOriginTrims:未要求全部修边时,
262
+ # 首刀纵向仅将贴上修边的余料延至 y=0,首刀横向则延至 x=0。
263
+ for r in layout.remnants:
264
+ x,y,w,h=r.x,r.y,r.width,r.height
265
+ if not require_all_trim_cuts:
266
+ if first_axis==0 and abs(y-opt.trim_top)<tol:
267
+ h+=y; y=0.
268
+ elif first_axis==1 and abs(x-opt.trim_left)<tol:
269
+ w+=x; x=0.
270
+ remaining_area+=w*h
271
+ index=len(offcut_rows)+1
272
+ offcut_rows.append(["OFFCUTS",1,index,f"X{index}",max(1,layout.material+1),dim(w),dim(h),1])
273
+ items.append((x,y,w,h,f"X{index}"))
274
+ # 使用显式任务栈构造树,不受 Python 递归深度或条带数量限制。
275
+ def split_node(region, members, previous_axis=None, depth=-1):
276
+ x,y,w,h=region
277
+ if not members:
278
+ index=len(offcut_rows)+1
279
+ offcut_rows.append(["OFFCUTS",1,index,f"X{index}",max(1,layout.material+1),dim(w),dim(h),1])
280
+ return ("leaf",f"X{index}")
281
+ if len(members)==1:
282
+ a,b,c,d,index=members[0]
283
+ if abs(a-x)<tol and abs(b-y)<tol and abs(c-w)<tol and abs(d-h)<tol:
284
+ return ("leaf",index)
285
+ preferred=first_axis if previous_axis is None else previous_axis
286
+ axis_order=(preferred,1-preferred)
287
+ for axis in axis_order:
288
+ origin=(x,y)[axis]; extent=(w,h)[axis]
289
+ candidates=sorted({item[axis] for item in members}|{item[axis]+item[axis+2] for item in members})
290
+ for position in candidates:
291
+ if position<=origin+tol or position>=origin+extent-tol: continue
292
+ if any(item[axis]+tol<position<item[axis]+item[axis+2]-tol for item in members): continue
293
+ before=[a for a in members if a[axis]+a[axis+2]<=position+tol]
294
+ after=[a for a in members if a[axis]+a[axis+2]>position+tol]
295
+ # 实际相邻零件的空隙至少覆盖指定锯缝。
296
+ gap=min((a[axis] for a in after),default=origin+extent)-position
297
+ kerf=min(opt.saw_width,max(0,gap))
298
+ if after and before and gap+tol<opt.saw_width: continue
299
+ left=list(region);right=list(region)
300
+ left[axis+2]=position-origin
301
+ right[axis]=position+kerf
302
+ right[axis+2]=origin+extent-position-kerf
303
+ margin_start=(opt.trim_left,opt.trim_top)[axis]
304
+ margin_end=(layout.width-opt.trim_right,layout.height-opt.trim_bottom)[axis]
305
+ trim_node=((margin_start>0 and abs(position-margin_start)<tol and not before)
306
+ or (abs(position-margin_end)<tol and not after
307
+ and (opt.trim_right,opt.trim_bottom)[axis]>0))
308
+ # 修边不增加正式加工层级;否则上修边、纵切、横切会被错误
309
+ # 算成三阶段,可能超过原始有效布局的 MaxCutLevel。
310
+ level=max(depth,0) if trim_node else depth+(axis!=previous_axis)
311
+ branches=[(left,before)]
312
+ if right[axis+2]>tol: branches.append((right,after))
313
+ return ("node",axis,level,[(tuple(r),m) for r,m in branches],trim_node,
314
+ previous_axis if trim_node else axis,depth if trim_node else level)
315
+ raise UnsupportedFeatureError("布局无法重建为合法 PTX 断头切割树")
316
+ root=[]
317
+ pending=[((0.,0.,layout.width,layout.height),items,None,-1,root)]
318
+ while pending:
319
+ region,members,previous_axis,depth,destination=pending.pop()
320
+ node=split_node(region,members,previous_axis,depth)
321
+ if node[0]=="leaf":
322
+ destination.extend(node)
323
+ continue
324
+ _,axis,level,branches,trim_node,next_axis,next_level=node
325
+ children=[(r[axis+2],[]) for r,m in branches]
326
+ destination.extend(("node",axis,level,children,trim_node))
327
+ for (r,m),(size,child) in reversed(list(zip(branches,children))):
328
+ pending.append((r,m,next_axis,next_level,child))
329
+ first_axis=root[1] if root[0]=="node" else 0
330
+ patterns.append(["PATTERNS",1,pattern,pattern,1 if first_axis==0 else 0,1,1,opt.max_layout_size,""])
331
+ record=1
332
+ sequence=0
333
+ sequences.append(["CUTS",1,pattern,record,0,0,0,0,0,0]);record+=1
334
+ if root[0]=="leaf":
335
+ # 整张即单件时使用无实际锯切的边界记录。
336
+ sequences.append(["CUTS",1,pattern,record,0,1,dim(layout.width),0,root[1],1]);record+=1
337
+ else:
338
+ pending=[(root[2],i,size,child,len(root[3]),root[4]) for i,(size,child) in reversed(list(enumerate(root[3])))]
339
+ while pending:
340
+ level,i,size,child,sibling_count,trim_node=pending.pop()
341
+ is_leaf=child[0]=="leaf"
342
+ item=child[1] if is_leaf else 0
343
+ actual=i<sibling_count-1
344
+ if actual: sequence+=1
345
+ operation=91+level if trim_node or (is_leaf and isinstance(item,str)) else level+1
346
+ sequences.append(["CUTS",1,pattern,record,sequence if actual else 0,operation,dim(size),1 if actual else 0,item,1 if is_leaf else 0]);record+=1
347
+ if not is_leaf:
348
+ pending.extend((child[2],j,s,c,len(child[3]),child[4]) for j,(s,c) in reversed(list(enumerate(child[3]))))
349
+ mats=list(materials)
350
+ max_material=max([layout.material for layout in result.layouts]+[p.material for l in result.layouts for p in l.parts]+[-1])
351
+ count=max(len(mats),max_material+1,1)
352
+ material_rows=[]
353
+ for i in range(count):
354
+ description=mats[i].description if i<len(mats) else material
355
+ thickness=mats[i].thickness if i<len(mats) else material_thickness
356
+ # 材料厚度已按所选计量模式给出;原 DLL 只缩放几何和锯缝。
357
+ material_rows.append(["MATERIALS",1,i+1,description,description,_n(round(thickness,2)),opt.max_layout_size,dim(opt.saw_width),dim(opt.saw_width),0,0,0,0,0,0,0,opt.max_cut_level,0,0,0,description,0])
358
+ area=sum(l.width*l.height for l in result.layouts)
359
+ today=date.today().strftime("%d/%m/%Y")
360
+ rows=[["HEADER","1.08","CutGLib",units,0,0],
361
+ ["JOBS",1,job_name,job_name,today,today,customer,1,"OPTIONS","SAW",int(result.elapsed),f"{100*remaining_area/area if area else 0:.2f}"]]
362
+ _write(target,_csv(rows+part_rows+stock_rows+material_rows+offcut_rows+patterns+sequences))