plottool 0.1.0__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.
- hpgl.py +1230 -0
- hpglpreview.py +382 -0
- plottool-0.1.0.dist-info/METADATA +227 -0
- plottool-0.1.0.dist-info/RECORD +9 -0
- plottool-0.1.0.dist-info/WHEEL +5 -0
- plottool-0.1.0.dist-info/entry_points.txt +2 -0
- plottool-0.1.0.dist-info/licenses/LICENSE +21 -0
- plottool-0.1.0.dist-info/top_level.txt +3 -0
- plottool.py +182 -0
hpgl.py
ADDED
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# HPGL parser, path manipulation, and optimization library.
|
|
3
|
+
# Coordinates are in HPGL machine units throughout (convert with mm2hpgl/hpgl2mm).
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import configparser
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
Point = tuple[float, float]
|
|
15
|
+
Path = list[Point]
|
|
16
|
+
|
|
17
|
+
HPGL_GOTO = "PU%s,%s;"
|
|
18
|
+
HPGL_CUTTO = "PD%s,%s;"
|
|
19
|
+
HPGL_CUTTO_STR = "PD%s;"
|
|
20
|
+
HPGL_INIT = "IN:;" # colon variant for Cogi compatibility; parser accepts both IN and IN:
|
|
21
|
+
HPGL_SELECT_PEN = "SP%s;"
|
|
22
|
+
HPGL_PEN_ABSOLUTE = "PA;"
|
|
23
|
+
|
|
24
|
+
# HPGL standard: 1016 machine units per inch (≈ 40 units/mm).
|
|
25
|
+
def mm2hpgl(value: float) -> float:
|
|
26
|
+
return value / 25.4 * 1016.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hpgl2mm(value: float) -> float:
|
|
30
|
+
return round(value, 0) / 1016.0 * 25.4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def vecDot(a: Point, b: Point) -> float:
|
|
34
|
+
return sum(map(lambda i: i[0] * i[1], zip(a, b)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def vecLen(a: Point) -> float:
|
|
38
|
+
return math.sqrt(vecDot(a, a))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def vecAngle(a: Point, b: Point, c: Point) -> float:
|
|
42
|
+
v0 = (a[0] - b[0], a[1] - b[1])
|
|
43
|
+
v1 = (c[0] - b[0], c[1] - b[1])
|
|
44
|
+
if a == c:
|
|
45
|
+
return 0
|
|
46
|
+
r = vecDot(v0, v1) / (vecLen(v0) * vecLen(v1))
|
|
47
|
+
if r >= -1 and r <= 1: # clamp before acos: floating-point errors can push r slightly outside [-1, 1]
|
|
48
|
+
return math.acos(r)
|
|
49
|
+
return math.pi
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def vecDist(a: Point, b: Point) -> float:
|
|
53
|
+
return vecLen((a[0] - b[0], a[1] - b[1]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def vecExtend(a: Point, b: Point, x: float) -> Point:
|
|
57
|
+
return a[0] + x * (b[0] - a[0]), a[1] + x * (b[1] - a[1])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _seg_intersect_t(p1: Point, p2: Point, p3: Point, p4: Point) -> Optional[float]:
|
|
61
|
+
"""Return t∈[0,1] where segment p1→p2 crosses segment p3→p4, or None."""
|
|
62
|
+
dx = p2[0] - p1[0]; dy = p2[1] - p1[1]
|
|
63
|
+
dx2 = p4[0] - p3[0]; dy2 = p4[1] - p3[1]
|
|
64
|
+
denom = dx * dy2 - dy * dx2
|
|
65
|
+
if abs(denom) < 1e-10:
|
|
66
|
+
return None
|
|
67
|
+
dx3 = p3[0] - p1[0]; dy3 = p3[1] - p1[1]
|
|
68
|
+
t = (dx3 * dy2 - dy3 * dx2) / denom
|
|
69
|
+
u = (dx3 * dy - dy3 * dx) / denom
|
|
70
|
+
if 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0:
|
|
71
|
+
return t
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _line_bbox_clip(p1: Point, p2: Point,
|
|
76
|
+
x0: float, y0: float, x1: float, y1: float) -> Optional[tuple[Point, Point]]:
|
|
77
|
+
"""Clip the infinite line through p1→p2 to the axis-aligned bbox [x0,x1]×[y0,y1]."""
|
|
78
|
+
dx = p2[0] - p1[0]
|
|
79
|
+
dy = p2[1] - p1[1]
|
|
80
|
+
t_min, t_max = -math.inf, math.inf
|
|
81
|
+
if abs(dx) < 1e-10:
|
|
82
|
+
if p1[0] < x0 or p1[0] > x1:
|
|
83
|
+
return None
|
|
84
|
+
else:
|
|
85
|
+
ta, tb = (x0 - p1[0]) / dx, (x1 - p1[0]) / dx
|
|
86
|
+
if ta > tb:
|
|
87
|
+
ta, tb = tb, ta
|
|
88
|
+
t_min = max(t_min, ta)
|
|
89
|
+
t_max = min(t_max, tb)
|
|
90
|
+
if abs(dy) < 1e-10:
|
|
91
|
+
if p1[1] < y0 or p1[1] > y1:
|
|
92
|
+
return None
|
|
93
|
+
else:
|
|
94
|
+
ta, tb = (y0 - p1[1]) / dy, (y1 - p1[1]) / dy
|
|
95
|
+
if ta > tb:
|
|
96
|
+
ta, tb = tb, ta
|
|
97
|
+
t_min = max(t_min, ta)
|
|
98
|
+
t_max = min(t_max, tb)
|
|
99
|
+
if t_min >= t_max:
|
|
100
|
+
return None
|
|
101
|
+
return (p1[0] + t_min * dx, p1[1] + t_min * dy), \
|
|
102
|
+
(p1[0] + t_max * dx, p1[1] + t_max * dy)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _point_parity(p: Point, routes: list[Path]) -> int:
|
|
106
|
+
"""Return 1 if p is inside an odd number of path regions (even-odd rule), else 0.
|
|
107
|
+
Casts a leftward ray from p and counts crossings with all route segments."""
|
|
108
|
+
py = p[1] + 0.001 # tiny nudge avoids hitting vertices or horizontal segments exactly
|
|
109
|
+
count = 0
|
|
110
|
+
for path in routes:
|
|
111
|
+
for a, b in zip(path, path[1:]):
|
|
112
|
+
ay, by_ = a[1] - py, b[1] - py
|
|
113
|
+
if (ay > 0) == (by_ > 0):
|
|
114
|
+
continue # segment doesn't straddle the ray's y level
|
|
115
|
+
cx = a[0] + ay / (ay - by_) * (b[0] - a[0])
|
|
116
|
+
if cx < p[0]:
|
|
117
|
+
count += 1
|
|
118
|
+
return count % 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _adaptive_clip(p1: Point, p2: Point, routes: list[Path]) -> list[tuple[Point, Point]]:
|
|
122
|
+
"""Split segment p1→p2 at every crossing with routes.
|
|
123
|
+
Keep sub-segments whose midpoint lies outside all design paths (even-odd rule).
|
|
124
|
+
Each midpoint is tested independently, avoiding cumulative parity corruption
|
|
125
|
+
from odd crossings with open paths."""
|
|
126
|
+
ts: list[float] = []
|
|
127
|
+
for path in routes:
|
|
128
|
+
for a, b in zip(path, path[1:]):
|
|
129
|
+
t = _seg_intersect_t(p1, p2, a, b)
|
|
130
|
+
if t is not None and 1e-9 < t < 1.0 - 1e-9:
|
|
131
|
+
ts.append(t)
|
|
132
|
+
ts.sort()
|
|
133
|
+
deduped: list[float] = []
|
|
134
|
+
for t in ts:
|
|
135
|
+
if not deduped or t - deduped[-1] > 1e-6:
|
|
136
|
+
deduped.append(t)
|
|
137
|
+
dx = p2[0] - p1[0]
|
|
138
|
+
dy = p2[1] - p1[1]
|
|
139
|
+
pts = [p1] + [(p1[0] + t * dx, p1[1] + t * dy) for t in deduped] + [p2]
|
|
140
|
+
return [
|
|
141
|
+
(pts[i], pts[i + 1])
|
|
142
|
+
for i in range(len(pts) - 1)
|
|
143
|
+
if _point_parity(((pts[i][0] + pts[i + 1][0]) / 2,
|
|
144
|
+
(pts[i][1] + pts[i + 1][1]) / 2), routes) == 0
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def hpgl_goto(match):
|
|
149
|
+
x = int(match.group(1))
|
|
150
|
+
y = int(match.group(2))
|
|
151
|
+
return HPGL_GOTO, (x, y)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def hpgl_pen_up(match):
|
|
155
|
+
x = None
|
|
156
|
+
y = None
|
|
157
|
+
return HPGL_GOTO, (x, y)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def hpgl_cutto(match):
|
|
161
|
+
x = int(match.group(1))
|
|
162
|
+
y = int(match.group(2))
|
|
163
|
+
return HPGL_CUTTO, (x, y)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def hpgl_cutto2(match):
|
|
167
|
+
coords = list(map(int, match.groups()[0].split(",")))
|
|
168
|
+
xy = list(zip(coords[0::2], coords[1::2]))
|
|
169
|
+
return HPGL_CUTTO, xy
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def hpgl_init(match):
|
|
173
|
+
return HPGL_INIT, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def hpgl_pen_absolute(match):
|
|
177
|
+
return HPGL_PEN_ABSOLUTE, None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def hpgl_select_pen(match):
|
|
181
|
+
pen = int(match.group(1))
|
|
182
|
+
return HPGL_SELECT_PEN, (pen,)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def path_start_stop(path: Path) -> tuple[Point, Point]:
|
|
186
|
+
return path[0], path[-1]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def path_center(path: Path) -> tuple[Point, Point]:
|
|
190
|
+
xvals, yvals = zip(*path)
|
|
191
|
+
max_x = max(xvals)
|
|
192
|
+
max_y = max(yvals)
|
|
193
|
+
min_x = min(xvals)
|
|
194
|
+
min_y = min(yvals)
|
|
195
|
+
|
|
196
|
+
start = (min_x + (max_x - min_x) / 2, min_y + (max_y - min_y) / 2)
|
|
197
|
+
return start, start
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def path_median(path: Path) -> tuple[Point, Point]:
|
|
201
|
+
xvals, yvals = zip(*path)
|
|
202
|
+
min_x = min(xvals)
|
|
203
|
+
min_y = min(yvals)
|
|
204
|
+
xvals = list(map(lambda x: x - min_x, xvals))
|
|
205
|
+
yvals = list(map(lambda y: y - min_y, yvals))
|
|
206
|
+
xmedian = sorted(xvals)[int(math.ceil(len(xvals) // 2))]
|
|
207
|
+
ymedian = sorted(yvals)[int(math.ceil(len(yvals) // 2))]
|
|
208
|
+
|
|
209
|
+
start = (min_x + xmedian, min_y + ymedian)
|
|
210
|
+
return start, start
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def path_mean(path: Path) -> tuple[Point, Point]:
|
|
214
|
+
xvals, yvals = zip(*path)
|
|
215
|
+
min_x = min(xvals)
|
|
216
|
+
min_y = min(yvals)
|
|
217
|
+
xvals = list(map(lambda x: x - min_x, xvals))
|
|
218
|
+
yvals = list(map(lambda y: y - min_y, yvals))
|
|
219
|
+
xmean = sum(xvals) / len(xvals)
|
|
220
|
+
ymean = sum(yvals) / len(yvals)
|
|
221
|
+
|
|
222
|
+
start = (min_x + xmean, min_y + ymean)
|
|
223
|
+
return start, start
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _weed_horizontal(x0: float, y0: float, x1: float, y1: float,
|
|
227
|
+
by0: float, by1: float, n: int) -> list[Path]:
|
|
228
|
+
"""n-1 horizontal lines that divide [by0, by1] into n equal segments; endpoints extend to x0/x1."""
|
|
229
|
+
if n <= 1:
|
|
230
|
+
return []
|
|
231
|
+
h = by1 - by0
|
|
232
|
+
return [[(x0, by0 + i * h / n), (x1, by0 + i * h / n)] for i in range(1, n)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _weed_vertical(x0: float, y0: float, x1: float, y1: float,
|
|
236
|
+
bx0: float, bx1: float, n: int) -> list[Path]:
|
|
237
|
+
"""n-1 vertical lines that divide [bx0, bx1] into n equal segments; endpoints extend to y0/y1."""
|
|
238
|
+
if n <= 1:
|
|
239
|
+
return []
|
|
240
|
+
w = bx1 - bx0
|
|
241
|
+
return [[(bx0 + i * w / n, y0), (bx0 + i * w / n, y1)] for i in range(1, n)]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _weed_grid(x0: float, y0: float, x1: float, y1: float,
|
|
245
|
+
bx0: float, by0: float, bx1: float, by1: float,
|
|
246
|
+
n_x: int, n_y: int) -> list[Path]:
|
|
247
|
+
return _weed_horizontal(x0, y0, x1, y1, by0, by1, n_y) + \
|
|
248
|
+
_weed_vertical(x0, y0, x1, y1, bx0, bx1, n_x)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _weed_frame(bx0: float, by0: float, bx1: float, by1: float, weed_size: float) -> list[Path]:
|
|
252
|
+
"""Concentric rectangular frames with equal-area rings.
|
|
253
|
+
Frame k is scaled by s_k = sqrt(1 - k·weed_size/100) from the bbox centre,
|
|
254
|
+
so each ring has area exactly weed_size% of the total bbox area."""
|
|
255
|
+
lines = []
|
|
256
|
+
w = bx1 - bx0
|
|
257
|
+
h = by1 - by0
|
|
258
|
+
k = 1
|
|
259
|
+
while True:
|
|
260
|
+
s2 = 1.0 - k * weed_size / 100.0
|
|
261
|
+
if s2 <= 0.0:
|
|
262
|
+
break
|
|
263
|
+
s = math.sqrt(s2)
|
|
264
|
+
xl = bx0 + w * (1.0 - s) / 2.0
|
|
265
|
+
xr = bx1 - w * (1.0 - s) / 2.0
|
|
266
|
+
yb = by0 + h * (1.0 - s) / 2.0
|
|
267
|
+
yt = by1 - h * (1.0 - s) / 2.0
|
|
268
|
+
if xl >= xr or yb >= yt:
|
|
269
|
+
break
|
|
270
|
+
lines += [
|
|
271
|
+
[(xl, yb), (xr, yb)],
|
|
272
|
+
[(xr, yb), (xr, yt)],
|
|
273
|
+
[(xr, yt), (xl, yt)],
|
|
274
|
+
[(xl, yt), (xl, yb)],
|
|
275
|
+
]
|
|
276
|
+
k += 1
|
|
277
|
+
return lines
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _diagonal_family(x0: float, y0: float, x1: float, y1: float,
|
|
281
|
+
c_ref: float, step: float, slope: int) -> list[Path]:
|
|
282
|
+
"""One family of parallel diagonal lines (slope ±1) clipped to the extended bbox."""
|
|
283
|
+
if step <= 0:
|
|
284
|
+
return []
|
|
285
|
+
c_min = (y0 - x1) if slope == 1 else (y0 + x0)
|
|
286
|
+
c_max = (y1 - x0) if slope == 1 else (y1 + x1)
|
|
287
|
+
k_min = math.ceil((c_min - c_ref) / step)
|
|
288
|
+
k_max = math.floor((c_max - c_ref) / step)
|
|
289
|
+
lines = []
|
|
290
|
+
for k in range(k_min, k_max + 1):
|
|
291
|
+
c = c_ref + k * step
|
|
292
|
+
p1 = (x0, slope * x0 + c)
|
|
293
|
+
p2 = (x1, slope * x1 + c)
|
|
294
|
+
seg = _line_bbox_clip(p1, p2, x0, y0, x1, y1)
|
|
295
|
+
if seg:
|
|
296
|
+
lines.append(list(seg))
|
|
297
|
+
return lines
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _weed_diagonal(x0: float, y0: float, x1: float, y1: float,
|
|
301
|
+
bx0: float, by0: float, bx1: float, by1: float,
|
|
302
|
+
n_x: int, n_y: int) -> list[Path]:
|
|
303
|
+
"""45° diagonal lines (y−x = c) equally spaced across the bbox."""
|
|
304
|
+
step = (bx1 - bx0) / n_x if n_x > 0 else 1.0
|
|
305
|
+
return _diagonal_family(x0, y0, x1, y1, by0 - bx0, step, slope=1)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _weed_rombic(x0: float, y0: float, x1: float, y1: float,
|
|
309
|
+
bx0: float, by0: float, bx1: float, by1: float,
|
|
310
|
+
n_x: int, n_y: int) -> list[Path]:
|
|
311
|
+
"""Both diagonal families (45° and 135°) forming a rhombic grid."""
|
|
312
|
+
step = (bx1 - bx0) / n_x if n_x > 0 else 1.0
|
|
313
|
+
return (_diagonal_family(x0, y0, x1, y1, by0 - bx0, step, slope=1) +
|
|
314
|
+
_diagonal_family(x0, y0, x1, y1, by0 + bx0, step, slope=-1))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _weed_tick(x0: float, y0: float, x1: float, y1: float,
|
|
318
|
+
bx0: float, by0: float, bx1: float, by1: float,
|
|
319
|
+
tick_hpgl: float, n_x: int, n_y: int) -> list[Path]:
|
|
320
|
+
"""Short inward comb-teeth from each bbox edge. n_x ticks on top/bottom, n_y on left/right."""
|
|
321
|
+
lines = []
|
|
322
|
+
for i in range(1, n_x):
|
|
323
|
+
tx = bx0 + i * (bx1 - bx0) / n_x
|
|
324
|
+
lines.append([(tx, y0), (tx, y0 + tick_hpgl)])
|
|
325
|
+
lines.append([(tx, y1), (tx, y1 - tick_hpgl)])
|
|
326
|
+
for i in range(1, n_y):
|
|
327
|
+
ty = by0 + i * (by1 - by0) / n_y
|
|
328
|
+
lines.append([(x0, ty), (x0 + tick_hpgl, ty)])
|
|
329
|
+
lines.append([(x1, ty), (x1 - tick_hpgl, ty)])
|
|
330
|
+
return lines
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ray_bbox_intersect(cx: float, cy: float, dx: float, dy: float,
|
|
334
|
+
x0: float, y0: float, x1: float, y1: float) -> Optional[Point]:
|
|
335
|
+
"""First intersection of forward ray (cx,cy)+t*(dx,dy), t>0, with the bbox boundary."""
|
|
336
|
+
t_best = math.inf
|
|
337
|
+
for wx in (x0, x1):
|
|
338
|
+
if abs(dx) > 1e-10:
|
|
339
|
+
t = (wx - cx) / dx
|
|
340
|
+
if t > 1e-9 and y0 <= cy + t * dy <= y1:
|
|
341
|
+
t_best = min(t_best, t)
|
|
342
|
+
for wy in (y0, y1):
|
|
343
|
+
if abs(dy) > 1e-10:
|
|
344
|
+
t = (wy - cy) / dy
|
|
345
|
+
if t > 1e-9 and x0 <= cx + t * dx <= x1:
|
|
346
|
+
t_best = min(t_best, t)
|
|
347
|
+
return None if t_best == math.inf else (cx + t_best * dx, cy + t_best * dy)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _weed_radial(x0: float, y0: float, x1: float, y1: float,
|
|
351
|
+
bx0: float, by0: float, bx1: float, by1: float,
|
|
352
|
+
weed_size: float, weed_small_size: float,
|
|
353
|
+
routes: list[Path]) -> list[Path]:
|
|
354
|
+
"""Spokes from the bbox centre at evenly-distributed angles (360°/n_spokes apart).
|
|
355
|
+
|
|
356
|
+
If the centre lies in waste (not inside a part), all spokes would converge
|
|
357
|
+
at one uncut spot which may tear the vinyl. In that case a small inner circle
|
|
358
|
+
of area weed_small_size% of bbox is added and spokes run from the circle boundary
|
|
359
|
+
to the perimeter instead of from the centre.
|
|
360
|
+
"""
|
|
361
|
+
cx, cy = (bx0 + bx1) / 2, (by0 + by1) / 2
|
|
362
|
+
W, H = bx1 - bx0, by1 - by0
|
|
363
|
+
|
|
364
|
+
center_in_part = bool(_point_parity((cx, cy), routes))
|
|
365
|
+
enclosing_routes: list[Path] = []
|
|
366
|
+
inner_r = 0.0
|
|
367
|
+
circle_r = 0.0
|
|
368
|
+
|
|
369
|
+
if not center_in_part:
|
|
370
|
+
enclosing_routes = [r for r in routes if _point_parity((cx, cy), [r])]
|
|
371
|
+
if enclosing_routes:
|
|
372
|
+
interior_bbox_area = min(
|
|
373
|
+
(max(p[0] for p in r) - min(p[0] for p in r)) *
|
|
374
|
+
(max(p[1] for p in r) - min(p[1] for p in r))
|
|
375
|
+
for r in enclosing_routes
|
|
376
|
+
)
|
|
377
|
+
if interior_bbox_area >= W * H * weed_size / 100.0:
|
|
378
|
+
enclosing_routes = []
|
|
379
|
+
inner_r = math.sqrt(W * H * weed_small_size / (100.0 * math.pi))
|
|
380
|
+
circle_r = inner_r
|
|
381
|
+
else:
|
|
382
|
+
# Centre is in open waste (e.g. gap between tiles with even repeat count).
|
|
383
|
+
# No route encloses it, so draw the inner circle so spokes don't all
|
|
384
|
+
# converge at one uncut point.
|
|
385
|
+
inner_r = math.sqrt(W * H * weed_small_size / (100.0 * math.pi))
|
|
386
|
+
circle_r = inner_r
|
|
387
|
+
|
|
388
|
+
effective_area = W * H - math.pi * inner_r ** 2
|
|
389
|
+
target_area = W * H * weed_size / 100.0
|
|
390
|
+
n_spokes = max(1, round(effective_area / target_area))
|
|
391
|
+
lines: list[Path] = []
|
|
392
|
+
|
|
393
|
+
for i in range(n_spokes):
|
|
394
|
+
angle = 2 * math.pi * i / n_spokes
|
|
395
|
+
dx, dy = math.cos(angle), math.sin(angle)
|
|
396
|
+
ep = _ray_bbox_intersect(cx, cy, dx, dy, x0, y0, x1, y1)
|
|
397
|
+
if ep is None:
|
|
398
|
+
continue
|
|
399
|
+
if inner_r > 0:
|
|
400
|
+
start: Point = (cx + inner_r * dx, cy + inner_r * dy)
|
|
401
|
+
elif enclosing_routes:
|
|
402
|
+
t_min = 1.0
|
|
403
|
+
for route in enclosing_routes:
|
|
404
|
+
for a, b in zip(route, route[1:]):
|
|
405
|
+
t = _seg_intersect_t((cx, cy), ep, a, b)
|
|
406
|
+
if t is not None and 1e-9 < t < t_min:
|
|
407
|
+
t_min = t
|
|
408
|
+
start = (cx + t_min * (ep[0] - cx), cy + t_min * (ep[1] - cy))
|
|
409
|
+
else:
|
|
410
|
+
start = (cx, cy)
|
|
411
|
+
lines.append([start, ep])
|
|
412
|
+
|
|
413
|
+
if circle_r > 0:
|
|
414
|
+
n = max(32, round(math.pi * circle_r / 20))
|
|
415
|
+
pts = [
|
|
416
|
+
(cx + circle_r * math.cos(2 * math.pi * i / n),
|
|
417
|
+
cy + circle_r * math.sin(2 * math.pi * i / n))
|
|
418
|
+
for i in range(n)
|
|
419
|
+
]
|
|
420
|
+
pts.append(pts[0])
|
|
421
|
+
lines.append(pts)
|
|
422
|
+
|
|
423
|
+
return lines
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _point_to_seg_closest(p: Point, a: Point, b: Point) -> tuple[float, Point]:
|
|
427
|
+
"""Closest point on segment a→b to p, and the distance."""
|
|
428
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
429
|
+
len2 = dx * dx + dy * dy
|
|
430
|
+
if len2 < 1e-12:
|
|
431
|
+
return vecDist(p, a), a
|
|
432
|
+
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2))
|
|
433
|
+
closest = (a[0] + t * dx, a[1] + t * dy)
|
|
434
|
+
return vecDist(p, closest), closest
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _weed_small_piece_filter(
|
|
438
|
+
segments: list[Path],
|
|
439
|
+
routes: list[Path],
|
|
440
|
+
x0: float, y0: float, x1: float, y1: float,
|
|
441
|
+
bbox_area: float,
|
|
442
|
+
weed_min_size: float,
|
|
443
|
+
) -> list[Path]:
|
|
444
|
+
"""Remove 2-point weeding segments that create waste pieces below weed_min_size% of bbox.
|
|
445
|
+
|
|
446
|
+
For each segment the smallest waste strip it could bound is approximated as
|
|
447
|
+
segment_length × min_clearance, where min_clearance is the minimum distance
|
|
448
|
+
from any interior sample point on the segment to a design path or the bbox edge.
|
|
449
|
+
Endpoints are excluded from sampling because adaptive clipping places them exactly
|
|
450
|
+
on design paths (distance 0), which would falsely remove every segment.
|
|
451
|
+
Multi-point paths (e.g. the radial inner circle) are passed through unchanged.
|
|
452
|
+
"""
|
|
453
|
+
if weed_min_size <= 0:
|
|
454
|
+
return segments
|
|
455
|
+
threshold = bbox_area * weed_min_size / 100.0
|
|
456
|
+
bbox_boundary: Path = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
|
|
457
|
+
boundaries = list(routes) + [bbox_boundary]
|
|
458
|
+
result: list[Path] = []
|
|
459
|
+
for seg in segments:
|
|
460
|
+
if len(seg) != 2:
|
|
461
|
+
result.append(seg)
|
|
462
|
+
continue
|
|
463
|
+
a, b = seg[0], seg[1]
|
|
464
|
+
length = vecDist(a, b)
|
|
465
|
+
total_clearance = 0.0
|
|
466
|
+
n_samples = 4
|
|
467
|
+
for i in range(1, n_samples + 1): # t = 0.2, 0.4, 0.6, 0.8 — interior only
|
|
468
|
+
t = i / (n_samples + 1)
|
|
469
|
+
p = (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
|
|
470
|
+
pt_clearance = float('inf')
|
|
471
|
+
for boundary in boundaries:
|
|
472
|
+
for ra, rb in zip(boundary, boundary[1:]):
|
|
473
|
+
d, _ = _point_to_seg_closest(p, ra, rb)
|
|
474
|
+
if d < pt_clearance:
|
|
475
|
+
pt_clearance = d
|
|
476
|
+
total_clearance += pt_clearance
|
|
477
|
+
mean_clearance = total_clearance / n_samples
|
|
478
|
+
if length * mean_clearance >= threshold:
|
|
479
|
+
result.append(seg)
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
HPGL_CMDS = {
|
|
484
|
+
re.compile(r"^PU(-?\d+),(-?\d+)$"): hpgl_goto,
|
|
485
|
+
re.compile(r"^PD(-?\d+),(-?\d+)$"): hpgl_cutto,
|
|
486
|
+
re.compile(r"^PD((-?\d+,-?\d+,)+(-?\d+),(-?\d+))$"): hpgl_cutto2,
|
|
487
|
+
re.compile(r"^PA$"): hpgl_pen_absolute,
|
|
488
|
+
re.compile(r"^PU$"): hpgl_pen_up,
|
|
489
|
+
re.compile(r"^IN:?$"): hpgl_init,
|
|
490
|
+
re.compile(r"^SP(\d+)$"): hpgl_select_pen}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class HPGL:
|
|
494
|
+
def __init__(self, fn: Optional[str]) -> None:
|
|
495
|
+
self.routes: list[Path] = []
|
|
496
|
+
if fn:
|
|
497
|
+
with open(fn) as f:
|
|
498
|
+
self.parse(f.read())
|
|
499
|
+
|
|
500
|
+
def parse(self, hpgldata: str) -> None:
|
|
501
|
+
commands = hpgldata.split(";")
|
|
502
|
+
routes = []
|
|
503
|
+
path = []
|
|
504
|
+
for command in commands:
|
|
505
|
+
command = command.strip()
|
|
506
|
+
if not command:
|
|
507
|
+
continue
|
|
508
|
+
matched = False
|
|
509
|
+
for CMD, func in HPGL_CMDS.items():
|
|
510
|
+
match = CMD.match(command)
|
|
511
|
+
if match:
|
|
512
|
+
cmd, params = func(match)
|
|
513
|
+
if cmd == HPGL_INIT:
|
|
514
|
+
pass
|
|
515
|
+
elif cmd == HPGL_SELECT_PEN:
|
|
516
|
+
pass
|
|
517
|
+
elif cmd == HPGL_PEN_ABSOLUTE:
|
|
518
|
+
pass
|
|
519
|
+
elif cmd == HPGL_GOTO:
|
|
520
|
+
if path:
|
|
521
|
+
if len(path) > 1:
|
|
522
|
+
routes.append(path)
|
|
523
|
+
path = [params, ]
|
|
524
|
+
elif cmd == HPGL_CUTTO:
|
|
525
|
+
if isinstance(params, list):
|
|
526
|
+
path.extend(params)
|
|
527
|
+
else:
|
|
528
|
+
if path[-1] != params:
|
|
529
|
+
path.append(params)
|
|
530
|
+
else:
|
|
531
|
+
raise Exception("what to do with \"" + cmd + "\" ?")
|
|
532
|
+
|
|
533
|
+
matched = True
|
|
534
|
+
break
|
|
535
|
+
if not matched:
|
|
536
|
+
print(repr(command))
|
|
537
|
+
if path:
|
|
538
|
+
if len(path) > 1:
|
|
539
|
+
routes.append(path)
|
|
540
|
+
self.routes = routes
|
|
541
|
+
|
|
542
|
+
def getPaths(self) -> list[Path]:
|
|
543
|
+
return self.routes
|
|
544
|
+
|
|
545
|
+
def getBoundingBox(self) -> tuple[Point, Point]:
|
|
546
|
+
max_x = None
|
|
547
|
+
max_y = None
|
|
548
|
+
min_x = None
|
|
549
|
+
min_y = None
|
|
550
|
+
for path in self.getPaths():
|
|
551
|
+
for x, y in path:
|
|
552
|
+
if max_x is None or x > max_x:
|
|
553
|
+
max_x = x
|
|
554
|
+
if min_x is None or x < min_x:
|
|
555
|
+
min_x = x
|
|
556
|
+
|
|
557
|
+
if max_y is None or y > max_y:
|
|
558
|
+
max_y = y
|
|
559
|
+
if min_y is None or y < min_y:
|
|
560
|
+
min_y = y
|
|
561
|
+
|
|
562
|
+
return ((min_x, min_y), (max_x, max_y))
|
|
563
|
+
|
|
564
|
+
def bladeOffset(self, offset: float) -> None:
|
|
565
|
+
# A rotating knife blade trails behind its pivot by `offset` mm.
|
|
566
|
+
# At each sharp corner we extend the incoming segment past the corner
|
|
567
|
+
# and start the outgoing segment slightly ahead of it, giving the blade
|
|
568
|
+
# time to swing into the new direction before cutting begins.
|
|
569
|
+
hpgl_offset = mm2hpgl(offset)
|
|
570
|
+
|
|
571
|
+
def _blade_offset(path):
|
|
572
|
+
new_path = []
|
|
573
|
+
new_path.append(path[0])
|
|
574
|
+
for prev, cur, next in zip(path[:-2], path[1:-1], path[2:]):
|
|
575
|
+
angle = vecAngle(prev, cur, next)
|
|
576
|
+
if angle < math.pi / 1.1: # ~164°: only correct at meaningful corners; near-straight angles need no adjustment
|
|
577
|
+
d2 = vecDist(cur, next)
|
|
578
|
+
ext2 = (4 * hpgl_offset) / d2
|
|
579
|
+
if ext2 <= 1.0:
|
|
580
|
+
d1 = vecDist(prev, cur)
|
|
581
|
+
ext1 = 1 + hpgl_offset / d1
|
|
582
|
+
new_path.append(vecExtend(prev, cur, ext1))
|
|
583
|
+
new_path.append(vecExtend(cur, next, ext2))
|
|
584
|
+
else:
|
|
585
|
+
new_path.append(cur)
|
|
586
|
+
else:
|
|
587
|
+
new_path.append(cur)
|
|
588
|
+
new_path.append(path[-1])
|
|
589
|
+
return new_path
|
|
590
|
+
self.operate(_blade_offset)
|
|
591
|
+
|
|
592
|
+
def optimize(self) -> None:
|
|
593
|
+
# Two-pass: first remove duplicates/subpixel points (required so the
|
|
594
|
+
# collinearity check in pass 2 doesn't fail on zero-length segments),
|
|
595
|
+
# then drop points that lie exactly on a straight line between neighbours.
|
|
596
|
+
def _optimize(path):
|
|
597
|
+
new_path = []
|
|
598
|
+
last = None
|
|
599
|
+
for p in path:
|
|
600
|
+
if p == last:
|
|
601
|
+
continue
|
|
602
|
+
last = p
|
|
603
|
+
new_path.append((int(round(p[0], 0)), int(round(p[1], 0))))
|
|
604
|
+
path = new_path
|
|
605
|
+
new_path = []
|
|
606
|
+
new_path.append(path[0])
|
|
607
|
+
prev = new_path[0]
|
|
608
|
+
for cur, next in zip(path[1:-1], path[2:]):
|
|
609
|
+
if cur == prev:
|
|
610
|
+
continue
|
|
611
|
+
if cur == next:
|
|
612
|
+
continue
|
|
613
|
+
angle = vecAngle(prev, cur, next)
|
|
614
|
+
if angle == math.pi:
|
|
615
|
+
continue
|
|
616
|
+
new_path.append(cur)
|
|
617
|
+
prev = cur
|
|
618
|
+
if new_path[-1] != path[-1]:
|
|
619
|
+
new_path.append(path[-1])
|
|
620
|
+
if len(new_path) == 1:
|
|
621
|
+
return None
|
|
622
|
+
if new_path[0] == new_path[-1]:
|
|
623
|
+
angle = vecAngle(new_path[-2], new_path[0], new_path[1])
|
|
624
|
+
if angle == math.pi:
|
|
625
|
+
new_path.pop(0)
|
|
626
|
+
new_path.pop(-1)
|
|
627
|
+
new_path.append(new_path[0])
|
|
628
|
+
return new_path
|
|
629
|
+
|
|
630
|
+
last = None
|
|
631
|
+
paths = self.getPaths()
|
|
632
|
+
self.routes = []
|
|
633
|
+
for path in paths:
|
|
634
|
+
if path[0] == last:
|
|
635
|
+
self.routes[-1].extend(path)
|
|
636
|
+
else:
|
|
637
|
+
self.routes.append(path)
|
|
638
|
+
last = path[-1]
|
|
639
|
+
self.operate(_optimize)
|
|
640
|
+
|
|
641
|
+
def optimizeCut(self, offset: float) -> None:
|
|
642
|
+
# For closed paths, reposition the start/end seam to the midpoint of the
|
|
643
|
+
# longest segment. This gives the blade maximum run-up before reaching
|
|
644
|
+
# the seam and a clean overcut exit, minimising the visible join mark.
|
|
645
|
+
hpgl_offset = mm2hpgl(offset) * 2
|
|
646
|
+
operations = []
|
|
647
|
+
|
|
648
|
+
def _optimizeCut(path):
|
|
649
|
+
if path[0] != path[-1]:
|
|
650
|
+
return path
|
|
651
|
+
index = None
|
|
652
|
+
maxlen = None
|
|
653
|
+
for j, coord in enumerate(zip(path[:-1], path[1:])):
|
|
654
|
+
cur, next = coord
|
|
655
|
+
l = vecDist(cur, next)
|
|
656
|
+
if maxlen is None or maxlen < l:
|
|
657
|
+
maxlen = l
|
|
658
|
+
index = j
|
|
659
|
+
a = vecExtend(path[index], path[index + 1], 0.5)
|
|
660
|
+
d = vecDist(path[index], path[index + 1])
|
|
661
|
+
b = vecExtend(path[index + 1], path[index], 0.5 - min(hpgl_offset / d, 0.5))
|
|
662
|
+
pre = [a, b]
|
|
663
|
+
if path[index + 1] == b:
|
|
664
|
+
pre = [a]
|
|
665
|
+
p = pre + path[index + 1:] + path[1:index + 1] + [a, b]
|
|
666
|
+
return p
|
|
667
|
+
|
|
668
|
+
self.operate(_optimizeCut)
|
|
669
|
+
|
|
670
|
+
def operate(self, fn: Callable[[Path], Optional[Path]]) -> None:
|
|
671
|
+
routes = []
|
|
672
|
+
for path in self.routes:
|
|
673
|
+
result = fn(path)
|
|
674
|
+
if result:
|
|
675
|
+
routes.append(result)
|
|
676
|
+
self.routes = routes
|
|
677
|
+
|
|
678
|
+
def operateXY(self, fn: Callable[[float, float], Point]) -> None:
|
|
679
|
+
self.operate(lambda path: list(map(lambda xy: fn(xy[0], xy[1]), path)))
|
|
680
|
+
|
|
681
|
+
def move(self, xoffset: float, yoffset: float) -> None:
|
|
682
|
+
self.operateXY(lambda x, y: (x + xoffset, y + yoffset))
|
|
683
|
+
|
|
684
|
+
def scale(self, xfactor: float, yfactor: Optional[float] = None) -> None:
|
|
685
|
+
if yfactor is None:
|
|
686
|
+
yfactor = xfactor
|
|
687
|
+
self.operateXY(lambda x, y: (x * xfactor, y * yfactor))
|
|
688
|
+
|
|
689
|
+
def fit(self) -> None:
|
|
690
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
691
|
+
x, y = min_xy
|
|
692
|
+
self.move(-x, -y)
|
|
693
|
+
|
|
694
|
+
def scaleToWidth(self, width: float) -> None:
|
|
695
|
+
new_width = mm2hpgl(width)
|
|
696
|
+
self.fit()
|
|
697
|
+
_, max_xy = self.getBoundingBox()
|
|
698
|
+
x, y = max_xy
|
|
699
|
+
factor = new_width / float(x)
|
|
700
|
+
self.scale(factor)
|
|
701
|
+
|
|
702
|
+
def scaleToHeight(self, height: float) -> None:
|
|
703
|
+
new_height = mm2hpgl(height)
|
|
704
|
+
self.fit()
|
|
705
|
+
_, max_xy = self.getBoundingBox()
|
|
706
|
+
x, y = max_xy
|
|
707
|
+
factor = new_height / float(y)
|
|
708
|
+
self.scale(factor)
|
|
709
|
+
|
|
710
|
+
def exportSVG(self, filename: str) -> None:
|
|
711
|
+
_, max_xy = self.getBoundingBox()
|
|
712
|
+
x, y = max_xy
|
|
713
|
+
svg = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
714
|
+
<svg
|
|
715
|
+
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
716
|
+
xmlns:cc="http://creativecommons.org/ns#"
|
|
717
|
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
718
|
+
xmlns:svg="http://www.w3.org/2000/svg"
|
|
719
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
720
|
+
units="mm"
|
|
721
|
+
width="{width:.3f}mm"
|
|
722
|
+
height="{height:.3f}mm"
|
|
723
|
+
viewBox="0 0 {width:.3f} {height:.3f}">
|
|
724
|
+
""".format(width=hpgl2mm(x), height=hpgl2mm(y))
|
|
725
|
+
last_x = 0
|
|
726
|
+
last_y = 0
|
|
727
|
+
for path in self.getPaths():
|
|
728
|
+
svg += "<path style=\"stroke:#0000ff;stroke-opacity:.8;fill:none;stroke-width:0.1;\" d=\"M %.3f,%.3f L %.3f,%.3f\"></path>\n" % tuple(map(hpgl2mm, (last_x, last_y, path[0][0], path[0][1])))
|
|
729
|
+
last_x = path[-1][0]
|
|
730
|
+
last_y = path[-1][1]
|
|
731
|
+
svg += "<path style=\"stroke:#ff0000;stroke-opacity:.8;fill:none;stroke-width:0.1;\" d=\""
|
|
732
|
+
first = True
|
|
733
|
+
for x, y in path:
|
|
734
|
+
if first:
|
|
735
|
+
first = False
|
|
736
|
+
svg += "M %.3f,%.3f" % (hpgl2mm(x), hpgl2mm(y))
|
|
737
|
+
else:
|
|
738
|
+
svg += " L %.3f,%.3f" % (hpgl2mm(x), hpgl2mm(y))
|
|
739
|
+
|
|
740
|
+
svg += "\"></path>\n"
|
|
741
|
+
svg += "<path style=\"stroke:#0000ff;stroke-opacity:.8;fill:none;stroke-width:0.1;\" d=\"M %.3f,%.3f L %.3f,%.3f\"></path>\n" % tuple(map(hpgl2mm, (last_x, last_y, 0, 0)))
|
|
742
|
+
svg += "</svg>"
|
|
743
|
+
with open(filename, "w") as f:
|
|
744
|
+
f.write(svg)
|
|
745
|
+
|
|
746
|
+
def mirrorX(self) -> None:
|
|
747
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
748
|
+
self.scale(-1, 1)
|
|
749
|
+
self.move(max_xy[0], 0)
|
|
750
|
+
|
|
751
|
+
def mirrorY(self) -> None:
|
|
752
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
753
|
+
self.scale(1, -1)
|
|
754
|
+
self.move(0, max_xy[1])
|
|
755
|
+
|
|
756
|
+
def rotate(self, degrees: float) -> None:
|
|
757
|
+
angle = degrees % 360
|
|
758
|
+
if angle == 0:
|
|
759
|
+
return
|
|
760
|
+
elif angle == 90:
|
|
761
|
+
self.operateXY(lambda x, y: (-y, x))
|
|
762
|
+
elif angle == 180:
|
|
763
|
+
self.mirrorX()
|
|
764
|
+
self.mirrorY()
|
|
765
|
+
return # mirrorX/mirrorY translate themselves
|
|
766
|
+
elif angle == 270:
|
|
767
|
+
self.operateXY(lambda x, y: (y, -x))
|
|
768
|
+
else:
|
|
769
|
+
cos_a = math.cos(math.radians(degrees))
|
|
770
|
+
sin_a = math.sin(math.radians(degrees))
|
|
771
|
+
self.operateXY(lambda x, y: (x * cos_a - y * sin_a, x * sin_a + y * cos_a))
|
|
772
|
+
self.fit()
|
|
773
|
+
|
|
774
|
+
def bladePrepCut(self, length: float = 2.0) -> None:
|
|
775
|
+
# A short cut at the origin seats the blade in the correct direction
|
|
776
|
+
# before the actual design begins.
|
|
777
|
+
self.routes.insert(0, [(0, 0), (mm2hpgl(length), 0)])
|
|
778
|
+
|
|
779
|
+
def addMargin(self, x: float, y: float) -> None:
|
|
780
|
+
self.move(mm2hpgl(x), mm2hpgl(y))
|
|
781
|
+
|
|
782
|
+
def getSize(self) -> tuple[float, float]:
|
|
783
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
784
|
+
return tuple(map(hpgl2mm, (max_xy[0] - min_xy[0], max_xy[1] - min_xy[1])))
|
|
785
|
+
|
|
786
|
+
def getLength(self) -> tuple[float, float]:
|
|
787
|
+
movement = 0
|
|
788
|
+
draw = 0
|
|
789
|
+
last = (0, 0)
|
|
790
|
+
for path in self.getPaths():
|
|
791
|
+
movement += vecDist(last, path[0])
|
|
792
|
+
draw += sum(map(lambda x: vecDist(x[0], x[1]), zip(path, path[1:])))
|
|
793
|
+
last = path[-1]
|
|
794
|
+
movement += vecDist(last, (0, 0))
|
|
795
|
+
return hpgl2mm(movement), hpgl2mm(draw)
|
|
796
|
+
|
|
797
|
+
def multiplyX(self, delta: float, m: int = 2, offset_y: float = 0.0, _step_x: Optional[float] = None) -> None:
|
|
798
|
+
if m < 2:
|
|
799
|
+
return
|
|
800
|
+
deltaHPGL = mm2hpgl(delta)
|
|
801
|
+
offsetHPGL = mm2hpgl(offset_y)
|
|
802
|
+
original = self.getPaths()
|
|
803
|
+
step_x = _step_x if _step_x is not None else self.getBoundingBox()[1][0]
|
|
804
|
+
for i in range(m - 1):
|
|
805
|
+
self.move(step_x + deltaHPGL, offsetHPGL)
|
|
806
|
+
self.routes = original + self.routes
|
|
807
|
+
|
|
808
|
+
def multiplyY(self, delta: float, m: int = 2, offset_x: float = 0.0, _step_y: Optional[float] = None) -> None:
|
|
809
|
+
if m < 2:
|
|
810
|
+
return
|
|
811
|
+
deltaHPGL = mm2hpgl(delta)
|
|
812
|
+
offsetHPGL = mm2hpgl(offset_x)
|
|
813
|
+
original = self.getPaths()
|
|
814
|
+
step_y = _step_y if _step_y is not None else self.getBoundingBox()[1][1]
|
|
815
|
+
for i in range(m - 1):
|
|
816
|
+
self.move(offsetHPGL, step_y + deltaHPGL)
|
|
817
|
+
self.routes = original + self.routes
|
|
818
|
+
|
|
819
|
+
def getHPGL(self) -> str:
|
|
820
|
+
hpgl = HPGL_INIT
|
|
821
|
+
hpgl += HPGL_PEN_ABSOLUTE
|
|
822
|
+
for route in self.routes:
|
|
823
|
+
route = tuple(map(lambda a: tuple(map(lambda b: int(round(b, 0)), a)), route))
|
|
824
|
+
goto = route[0]
|
|
825
|
+
route = ",".join(map(lambda a: "%d,%d" % a, route[1:]))
|
|
826
|
+
hpgl += HPGL_GOTO % goto
|
|
827
|
+
hpgl += HPGL_CUTTO_STR % route
|
|
828
|
+
hpgl += HPGL_GOTO % (0, 0)
|
|
829
|
+
hpgl += HPGL_SELECT_PEN % 0 # SP0 twice: some plotters only retract the blade/pen on the second command
|
|
830
|
+
hpgl += HPGL_SELECT_PEN % 0
|
|
831
|
+
return hpgl
|
|
832
|
+
|
|
833
|
+
def exportHPGL(self, filename: str) -> None:
|
|
834
|
+
with open(filename, "w") as f:
|
|
835
|
+
f.write(self.getHPGL())
|
|
836
|
+
|
|
837
|
+
def rerouteNearest(self, xweight: float = 1, yweight: float = 2,
|
|
838
|
+
pathfn: Callable[[Path], tuple[Point, Point]] = path_center) -> None:
|
|
839
|
+
# Greedy nearest-neighbour reorder. yweight=2 by default because the
|
|
840
|
+
# plotter carriage moves faster along X, so Y travel costs more time.
|
|
841
|
+
last_p = (0, 0)
|
|
842
|
+
paths = self.getPaths()
|
|
843
|
+
self.routes = []
|
|
844
|
+
distance = None
|
|
845
|
+
next_path = None
|
|
846
|
+
next_path_stop = None
|
|
847
|
+
while paths:
|
|
848
|
+
for path in paths:
|
|
849
|
+
path_start, path_stop = pathfn(path)
|
|
850
|
+
d = math.sqrt(((path_start[0] - last_p[0]) * xweight) ** 2 + ((path_start[1] - last_p[1]) * yweight) ** 2)
|
|
851
|
+
if distance is None or distance > d:
|
|
852
|
+
distance = d
|
|
853
|
+
next_path = path
|
|
854
|
+
next_path_stop = path_stop
|
|
855
|
+
if next_path:
|
|
856
|
+
self.routes.append(next_path)
|
|
857
|
+
paths.remove(next_path)
|
|
858
|
+
last_p = next_path_stop
|
|
859
|
+
next_path = None
|
|
860
|
+
distance = None
|
|
861
|
+
|
|
862
|
+
def rerouteXY(self, rowsize: int = 600,
|
|
863
|
+
pathfn: Callable[[Path], tuple[Point, Point]] = path_start_stop) -> None:
|
|
864
|
+
# Boustrophedon (snake) order: sort paths into horizontal rows, then
|
|
865
|
+
# alternate row direction so the pen reverses rather than returning to
|
|
866
|
+
# the start of each row, minimising total pen-up travel.
|
|
867
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
868
|
+
x, y = max_xy
|
|
869
|
+
_, min_y = min_xy
|
|
870
|
+
rows = [[] for i in range(int((y - min_y) // rowsize + 1))]
|
|
871
|
+
for path in self.getPaths():
|
|
872
|
+
start, stop = pathfn(path)
|
|
873
|
+
x, y = start
|
|
874
|
+
row = int((y - min_y) // rowsize)
|
|
875
|
+
rows[row].append((start, path))
|
|
876
|
+
reverse = False
|
|
877
|
+
self.routes = []
|
|
878
|
+
|
|
879
|
+
for row in rows:
|
|
880
|
+
if row:
|
|
881
|
+
self.routes.extend(map(lambda a: a[1], sorted(row, reverse=reverse)))
|
|
882
|
+
reverse = not reverse
|
|
883
|
+
|
|
884
|
+
def addWeedingLines(self, strategy: str = 'grid',
|
|
885
|
+
min_spacing_x: float = 1.0, max_spacing_x: float = math.inf,
|
|
886
|
+
min_spacing_y: float = 1.0, max_spacing_y: float = math.inf,
|
|
887
|
+
margin: float = 2.0, tick_length: float = 5.0,
|
|
888
|
+
adaptive: bool = True,
|
|
889
|
+
add_frame: bool = True, frame_distance: float = 1.0,
|
|
890
|
+
weed_size: float = 25.0, weed_small_size: float = 0.0,
|
|
891
|
+
weed_min_size: float = 0.0) -> None:
|
|
892
|
+
min_xy, max_xy = self.getBoundingBox()
|
|
893
|
+
mx = mm2hpgl(margin)
|
|
894
|
+
x0, y0 = min_xy[0] - mx, min_xy[1] - mx
|
|
895
|
+
x1, y1 = max_xy[0] + mx, max_xy[1] + mx
|
|
896
|
+
|
|
897
|
+
bx0, by0 = min_xy[0], min_xy[1]
|
|
898
|
+
bx1, by1 = max_xy[0], max_xy[1]
|
|
899
|
+
|
|
900
|
+
if not hasattr(self, '_design_bbox'):
|
|
901
|
+
self._design_bbox = (min_xy, max_xy)
|
|
902
|
+
|
|
903
|
+
# 1D strategies: each strip ≤ weed_size% of total area → n = ⌈100/weed_size⌉
|
|
904
|
+
# 2D strategies: each cell ≤ weed_size% of total area → n = ⌈√(100/weed_size)⌉
|
|
905
|
+
n_1d = max(1, math.ceil(100.0 / weed_size))
|
|
906
|
+
n_2d = max(1, math.ceil(math.sqrt(100.0 / weed_size)))
|
|
907
|
+
|
|
908
|
+
def _clamp_n(n, size, min_sp, max_sp):
|
|
909
|
+
if size <= 0:
|
|
910
|
+
return 1
|
|
911
|
+
if min_sp > 0:
|
|
912
|
+
n = min(n, int(size / mm2hpgl(min_sp)))
|
|
913
|
+
if max_sp < math.inf:
|
|
914
|
+
n = max(n, math.ceil(size / mm2hpgl(max_sp)))
|
|
915
|
+
return max(n, 1)
|
|
916
|
+
|
|
917
|
+
n_x = _clamp_n(n_2d, bx1 - bx0, min_spacing_x, max_spacing_x)
|
|
918
|
+
n_y = _clamp_n(n_2d, by1 - by0, min_spacing_y, max_spacing_y)
|
|
919
|
+
|
|
920
|
+
original_routes = self.routes[:]
|
|
921
|
+
if weed_small_size <= 0:
|
|
922
|
+
weed_small_size = weed_size / 10.0
|
|
923
|
+
if weed_min_size <= 0:
|
|
924
|
+
weed_min_size = weed_small_size / 10.0
|
|
925
|
+
|
|
926
|
+
dispatch = {
|
|
927
|
+
'grid': lambda: _weed_grid(x0, y0, x1, y1, bx0, by0, bx1, by1, n_x, n_y),
|
|
928
|
+
'horizontal': lambda: _weed_horizontal(x0, y0, x1, y1, by0, by1, n_1d),
|
|
929
|
+
'vertical': lambda: _weed_vertical(x0, y0, x1, y1, bx0, bx1, n_1d),
|
|
930
|
+
'frame': lambda: _weed_frame(bx0, by0, bx1, by1, weed_size),
|
|
931
|
+
'diagonal': lambda: _weed_diagonal(x0, y0, x1, y1, bx0, by0, bx1, by1, n_x, n_y),
|
|
932
|
+
'rombic': lambda: _weed_rombic(x0, y0, x1, y1, bx0, by0, bx1, by1, n_x, n_y),
|
|
933
|
+
'tick': lambda: _weed_tick(x0, y0, x1, y1, bx0, by0, bx1, by1, mm2hpgl(tick_length), n_x, n_y),
|
|
934
|
+
'radial': lambda: _weed_radial(x0, y0, x1, y1, bx0, by0, bx1, by1, weed_size, weed_small_size, original_routes),
|
|
935
|
+
}
|
|
936
|
+
fn = dispatch.get(strategy)
|
|
937
|
+
if fn is None:
|
|
938
|
+
raise ValueError("Unknown weeding strategy {!r}. Available: {}".format(
|
|
939
|
+
strategy, ', '.join(dispatch)))
|
|
940
|
+
new_lines = fn()
|
|
941
|
+
|
|
942
|
+
if adaptive:
|
|
943
|
+
clipped = []
|
|
944
|
+
for seg in new_lines:
|
|
945
|
+
if len(seg) == 2:
|
|
946
|
+
for a, b in _adaptive_clip(seg[0], seg[1], original_routes):
|
|
947
|
+
if vecDist(a, b) > 1.0:
|
|
948
|
+
clipped.append([a, b])
|
|
949
|
+
else:
|
|
950
|
+
# Multi-point polyline: clip each segment and chain consecutive kept pieces.
|
|
951
|
+
chain: list[Point] = []
|
|
952
|
+
for i in range(len(seg) - 1):
|
|
953
|
+
sub = _adaptive_clip(seg[i], seg[i + 1], original_routes)
|
|
954
|
+
for a, b in sub:
|
|
955
|
+
if vecDist(a, b) <= 1.0:
|
|
956
|
+
continue
|
|
957
|
+
if chain and vecDist(chain[-1], a) < 1.0:
|
|
958
|
+
chain.append(b)
|
|
959
|
+
else:
|
|
960
|
+
if len(chain) >= 2:
|
|
961
|
+
clipped.append(chain)
|
|
962
|
+
chain = [a, b]
|
|
963
|
+
if not sub and chain:
|
|
964
|
+
clipped.append(chain)
|
|
965
|
+
chain = []
|
|
966
|
+
if len(chain) >= 2:
|
|
967
|
+
clipped.append(chain)
|
|
968
|
+
new_lines = clipped
|
|
969
|
+
|
|
970
|
+
if weed_min_size > 0:
|
|
971
|
+
bbox_area = (bx1 - bx0) * (by1 - by0)
|
|
972
|
+
new_lines = _weed_small_piece_filter(
|
|
973
|
+
new_lines, original_routes, x0, y0, x1, y1, bbox_area, weed_min_size)
|
|
974
|
+
|
|
975
|
+
self.routes.extend(new_lines)
|
|
976
|
+
|
|
977
|
+
if add_frame:
|
|
978
|
+
fd = mm2hpgl(frame_distance)
|
|
979
|
+
fx0, fy0 = bx0 - fd, by0 - fd
|
|
980
|
+
fx1, fy1 = bx1 + fd, by1 + fd
|
|
981
|
+
self.routes += [
|
|
982
|
+
[(fx0, fy0), (fx1, fy0)],
|
|
983
|
+
[(fx1, fy0), (fx1, fy1)],
|
|
984
|
+
[(fx1, fy1), (fx0, fy1)],
|
|
985
|
+
[(fx0, fy1), (fx0, fy0)],
|
|
986
|
+
]
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _read_config_files() -> configparser.ConfigParser:
|
|
990
|
+
config = configparser.ConfigParser()
|
|
991
|
+
config.read([os.path.expanduser('~/.plottoolrc'), 'plottool.conf'])
|
|
992
|
+
return config
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def load_config(profile: str = None) -> dict:
|
|
996
|
+
config = _read_config_files()
|
|
997
|
+
result = {}
|
|
998
|
+
if 'default' in config:
|
|
999
|
+
result.update(config['default'])
|
|
1000
|
+
if profile:
|
|
1001
|
+
if profile in config:
|
|
1002
|
+
result.update(config[profile])
|
|
1003
|
+
else:
|
|
1004
|
+
print(f"Warning: config profile '{profile}' not found", file=sys.stderr)
|
|
1005
|
+
return result
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def load_plotter_config(name: str) -> dict:
|
|
1009
|
+
config = _read_config_files()
|
|
1010
|
+
section = f'plotter:{name}'
|
|
1011
|
+
if section not in config:
|
|
1012
|
+
print(f"Warning: plotter '{name}' not found in config", file=sys.stderr)
|
|
1013
|
+
return {}
|
|
1014
|
+
return {f'plotter-{k}': v for k, v in config[section].items()}
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def apply_config_to_parser(parser, config: dict) -> None:
|
|
1018
|
+
cli_specified = {
|
|
1019
|
+
action.dest
|
|
1020
|
+
for action in parser._actions
|
|
1021
|
+
for opt in action.option_strings
|
|
1022
|
+
if opt in sys.argv[1:]
|
|
1023
|
+
}
|
|
1024
|
+
# If any member of a mutually exclusive group is on the CLI,
|
|
1025
|
+
# skip all members of that group from config to avoid silent conflicts.
|
|
1026
|
+
excluded = set()
|
|
1027
|
+
for group in parser._mutually_exclusive_groups:
|
|
1028
|
+
group_dests = {a.dest for a in group._group_actions}
|
|
1029
|
+
if group_dests & cli_specified:
|
|
1030
|
+
excluded |= group_dests
|
|
1031
|
+
|
|
1032
|
+
skip = cli_specified | excluded
|
|
1033
|
+
action_map = {a.dest: a for a in parser._actions}
|
|
1034
|
+
converted = {}
|
|
1035
|
+
for key, value in config.items():
|
|
1036
|
+
dest = key.replace('-', '_')
|
|
1037
|
+
if dest not in action_map or dest in skip:
|
|
1038
|
+
continue
|
|
1039
|
+
action = action_map[dest]
|
|
1040
|
+
if action.const is True: # store_true
|
|
1041
|
+
converted[dest] = value.lower() in ('true', 'yes', '1', 'on')
|
|
1042
|
+
elif action.type is not None:
|
|
1043
|
+
try:
|
|
1044
|
+
converted[dest] = action.type(value)
|
|
1045
|
+
except (ValueError, TypeError):
|
|
1046
|
+
print(f"Warning: invalid config value for '{key}': {value!r}", file=sys.stderr)
|
|
1047
|
+
else:
|
|
1048
|
+
converted[dest] = value
|
|
1049
|
+
parser.set_defaults(**converted)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def apply_plotter_transform(hpgl_obj: HPGL, args) -> None:
|
|
1053
|
+
rotate = getattr(args, 'plotter_rotate', None)
|
|
1054
|
+
if rotate:
|
|
1055
|
+
hpgl_obj.rotate(float(rotate))
|
|
1056
|
+
if getattr(args, 'plotter_mirror', False):
|
|
1057
|
+
hpgl_obj.mirrorX()
|
|
1058
|
+
if getattr(args, 'plotter_flip', False):
|
|
1059
|
+
hpgl_obj.mirrorY()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def apply_args(hpgl_obj: HPGL, args) -> None:
|
|
1063
|
+
blade_optimize = False
|
|
1064
|
+
optimize = False
|
|
1065
|
+
mirror = args.mirror
|
|
1066
|
+
flip = getattr(args, 'flip', False)
|
|
1067
|
+
|
|
1068
|
+
if args.magic:
|
|
1069
|
+
blade_optimize = True
|
|
1070
|
+
optimize = True
|
|
1071
|
+
|
|
1072
|
+
rotate = getattr(args, 'rotate', None)
|
|
1073
|
+
|
|
1074
|
+
if args.width is not None:
|
|
1075
|
+
hpgl_obj.scaleToWidth(args.width)
|
|
1076
|
+
elif getattr(args, 'height', None) is not None:
|
|
1077
|
+
hpgl_obj.scaleToHeight(args.height)
|
|
1078
|
+
elif getattr(args, 'scale', None) is not None:
|
|
1079
|
+
hpgl_obj.scale(args.scale)
|
|
1080
|
+
|
|
1081
|
+
if getattr(args, 'pen', False):
|
|
1082
|
+
blade_optimize = False
|
|
1083
|
+
|
|
1084
|
+
if rotate is not None:
|
|
1085
|
+
hpgl_obj.rotate(rotate)
|
|
1086
|
+
|
|
1087
|
+
if mirror:
|
|
1088
|
+
hpgl_obj.mirrorX()
|
|
1089
|
+
|
|
1090
|
+
if flip:
|
|
1091
|
+
hpgl_obj.mirrorY()
|
|
1092
|
+
|
|
1093
|
+
if optimize:
|
|
1094
|
+
hpgl_obj.optimize()
|
|
1095
|
+
hpgl_obj.fit()
|
|
1096
|
+
|
|
1097
|
+
if blade_optimize:
|
|
1098
|
+
blade_offset = getattr(args, 'blade_offset', 0.25) or 0.25
|
|
1099
|
+
hpgl_obj.optimizeCut(blade_offset)
|
|
1100
|
+
hpgl_obj.bladeOffset(blade_offset)
|
|
1101
|
+
|
|
1102
|
+
repeat_x = getattr(args, 'repeat_x', 1) or 1
|
|
1103
|
+
repeat_y = getattr(args, 'repeat_y', 1) or 1
|
|
1104
|
+
gap = getattr(args, 'gap', 5.0)
|
|
1105
|
+
if gap is None:
|
|
1106
|
+
gap = 5.0
|
|
1107
|
+
gap_x = getattr(args, 'gap_x', None)
|
|
1108
|
+
gap_y = getattr(args, 'gap_y', None)
|
|
1109
|
+
if gap_x is None:
|
|
1110
|
+
gap_x = gap
|
|
1111
|
+
if gap_y is None:
|
|
1112
|
+
gap_y = gap
|
|
1113
|
+
offset_x = getattr(args, 'offset_x', 0.0) or 0.0
|
|
1114
|
+
offset_y = getattr(args, 'offset_y', 0.0) or 0.0
|
|
1115
|
+
if repeat_x > 1 or repeat_y > 1:
|
|
1116
|
+
_, (orig_max_x, orig_max_y) = hpgl_obj.getBoundingBox()
|
|
1117
|
+
if repeat_x > 1:
|
|
1118
|
+
hpgl_obj.multiplyX(gap_x, repeat_x, offset_y, _step_x=orig_max_x)
|
|
1119
|
+
if repeat_y > 1:
|
|
1120
|
+
hpgl_obj.multiplyY(gap_y, repeat_y, offset_x, _step_y=orig_max_y)
|
|
1121
|
+
|
|
1122
|
+
weed = getattr(args, 'weed', None)
|
|
1123
|
+
if weed:
|
|
1124
|
+
hpgl_obj.addWeedingLines(
|
|
1125
|
+
strategy=weed,
|
|
1126
|
+
min_spacing_x=getattr(args, 'weed_min_x', 1.0) or 1.0,
|
|
1127
|
+
max_spacing_x=getattr(args, 'weed_max_x', None) or float('inf'),
|
|
1128
|
+
min_spacing_y=getattr(args, 'weed_min_y', 1.0) or 1.0,
|
|
1129
|
+
max_spacing_y=getattr(args, 'weed_max_y', None) or float('inf'),
|
|
1130
|
+
margin=getattr(args, 'weed_margin', 2.0) or 2.0,
|
|
1131
|
+
tick_length=getattr(args, 'weed_tick_length', 5.0) or 5.0,
|
|
1132
|
+
adaptive=not getattr(args, 'no_weed_adaptive', False),
|
|
1133
|
+
add_frame=not getattr(args, 'no_weed_frame', False),
|
|
1134
|
+
frame_distance=getattr(args, 'weed_frame_distance', 1.0) or 1.0,
|
|
1135
|
+
weed_size=getattr(args, 'weed_size', 25.0) or 25.0,
|
|
1136
|
+
weed_small_size=getattr(args, 'weed_small_size', 0.0),
|
|
1137
|
+
weed_min_size=getattr(args, 'weed_min_size', 0.0),
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
reroute = getattr(args, 'reroute', 'xy')
|
|
1141
|
+
if reroute == 'xy':
|
|
1142
|
+
hpgl_obj.rerouteXY()
|
|
1143
|
+
elif reroute == 'nearest':
|
|
1144
|
+
hpgl_obj.rerouteNearest()
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
if __name__ == "__main__":
|
|
1148
|
+
import argparse
|
|
1149
|
+
parser = argparse.ArgumentParser("HPGL modification/optimization tool")
|
|
1150
|
+
parser.add_argument("file", type=str, help="the HPGL-file to edit")
|
|
1151
|
+
parser.add_argument("--profile", metavar="NAME", help="Config profile to load from ~/.plottoolrc or plottool.conf")
|
|
1152
|
+
parser.add_argument("--plotter", metavar="NAME", help="Plotter profile to load from ~/.plottoolrc or plottool.conf ([plotter:NAME] section)")
|
|
1153
|
+
plotter_group = parser.add_argument_group("plotter transform (applied last, after all other operations)")
|
|
1154
|
+
plotter_group.add_argument("--plotter-mirror", action="store_true", help="Mirror on X-axis (plotter-level correction)")
|
|
1155
|
+
plotter_group.add_argument("--plotter-flip", action="store_true", help="Flip on Y-axis (plotter-level correction)")
|
|
1156
|
+
plotter_group.add_argument("--plotter-rotate", metavar="DEG", type=int, choices=[0, 90, 180, 270], default=None, help="Rotate by 90° steps (plotter-level correction)")
|
|
1157
|
+
parser.add_argument("-p", "--preview", type=str, help="Generate SVG preview file", metavar="SVG")
|
|
1158
|
+
parser.add_argument("-o", "--output", type=str, help="Output HPGL file", metavar="HPGL")
|
|
1159
|
+
parser.add_argument("-m", "--magic", action="store_true", help="Enable auto-optimize")
|
|
1160
|
+
scale_group = parser.add_mutually_exclusive_group()
|
|
1161
|
+
scale_group.add_argument("-w", "--width", metavar="MM", type=float, help="Scale to width in mm")
|
|
1162
|
+
scale_group.add_argument("-H", "--height", metavar="MM", type=float, help="Scale to height in mm")
|
|
1163
|
+
scale_group.add_argument("-s", "--scale", metavar="FACTOR", type=float, help="Scale by factor (e.g. 0.5 for half size)")
|
|
1164
|
+
parser.add_argument("--mirror", action="store_true", help="Mirror on X-axis for inverted cuts (T-Shirts etc.)")
|
|
1165
|
+
parser.add_argument("--flip", action="store_true", help="Flip on Y-axis (mirror top to bottom)")
|
|
1166
|
+
parser.add_argument("--rotate", metavar="DEG", type=float, help="Rotate design by angle in degrees (counter-clockwise)")
|
|
1167
|
+
parser.add_argument("--pen", action="store_true", help="Disable cut optimization for rotating knifes")
|
|
1168
|
+
parser.add_argument("--blade-offset", metavar="MM", type=float, default=0.25, help="Blade offset in mm (default: 0.25, ignored with --pen)")
|
|
1169
|
+
parser.add_argument("--no-blade-prep", action="store_true", help="Skip the 2mm prep cut at origin used to seat the blade")
|
|
1170
|
+
parser.add_argument("--reroute", choices=["xy", "nearest", "none"], default="xy",
|
|
1171
|
+
help="Reroute paths: xy (boustrophedon, default), nearest (greedy), none (keep original order)")
|
|
1172
|
+
parser.add_argument("--repeat-x", metavar="N", type=int, default=1, help="Tile N times along X axis")
|
|
1173
|
+
parser.add_argument("--repeat-y", metavar="N", type=int, default=1, help="Tile N times along Y axis")
|
|
1174
|
+
parser.add_argument("--gap", metavar="MM", type=float, default=5.0, help="Gap between tiles in mm for both axes (default: 5)")
|
|
1175
|
+
parser.add_argument("--gap-x", metavar="MM", type=float, default=None, help="Gap between tiles along X axis in mm; overrides --gap (negative = overlap)")
|
|
1176
|
+
parser.add_argument("--gap-y", metavar="MM", type=float, default=None, help="Gap between tiles along Y axis in mm; overrides --gap (negative = overlap)")
|
|
1177
|
+
parser.add_argument("--offset-x", metavar="MM", type=float, default=0.0, help="X offset per step when repeating along Y axis in mm (stagger rows)")
|
|
1178
|
+
parser.add_argument("--offset-y", metavar="MM", type=float, default=0.0, help="Y offset per step when repeating along X axis in mm (stagger columns)")
|
|
1179
|
+
weed_group = parser.add_argument_group("weeding lines")
|
|
1180
|
+
weed_group.add_argument("--weed", metavar="STRATEGY",
|
|
1181
|
+
choices=["grid", "horizontal", "vertical", "frame",
|
|
1182
|
+
"diagonal", "rombic", "tick", "radial"],
|
|
1183
|
+
help="Add weeding lines (grid, horizontal, vertical, frame, "
|
|
1184
|
+
"diagonal, rombic, tick, radial)")
|
|
1185
|
+
weed_group.add_argument("--weed-min-x", metavar="MM", type=float, default=1.0,
|
|
1186
|
+
help="Min spacing between vertical weeding lines in mm (default: 1)")
|
|
1187
|
+
weed_group.add_argument("--weed-max-x", metavar="MM", type=float, default=None,
|
|
1188
|
+
help="Max spacing between vertical weeding lines in mm (default: unlimited)")
|
|
1189
|
+
weed_group.add_argument("--weed-min-y", metavar="MM", type=float, default=1.0,
|
|
1190
|
+
help="Min spacing between horizontal weeding lines in mm (default: 1)")
|
|
1191
|
+
weed_group.add_argument("--weed-max-y", metavar="MM", type=float, default=None,
|
|
1192
|
+
help="Max spacing between horizontal weeding lines in mm (default: unlimited)")
|
|
1193
|
+
weed_group.add_argument("--weed-margin", metavar="MM", type=float, default=2.0,
|
|
1194
|
+
help="Extend weeding lines beyond bbox in mm (default: 2)")
|
|
1195
|
+
weed_group.add_argument("--weed-tick-length", metavar="MM", type=float, default=5.0,
|
|
1196
|
+
help="Tick/comb tooth length in mm (default: 5)")
|
|
1197
|
+
weed_group.add_argument("--weed-size", metavar="PCT", type=float, default=25.0,
|
|
1198
|
+
help="Max waste piece size as %% of bbox area (default: 25)")
|
|
1199
|
+
weed_group.add_argument("--weed-small-size", metavar="PCT", type=float, default=0.0,
|
|
1200
|
+
help="Radial inner circle area as %% of bbox area (default: weed-size/10)")
|
|
1201
|
+
weed_group.add_argument("--weed-min-size", metavar="PCT", type=float, default=0.0,
|
|
1202
|
+
help="Remove weeding lines creating waste pieces smaller than PCT%% of bbox (default: weed-small-size/10)")
|
|
1203
|
+
weed_group.add_argument("--no-weed-adaptive", action="store_true",
|
|
1204
|
+
help="Disable splitting weeding lines at design intersections")
|
|
1205
|
+
weed_group.add_argument("--no-weed-frame", action="store_true",
|
|
1206
|
+
help="Disable the outer frame rectangle around the bbox")
|
|
1207
|
+
weed_group.add_argument("--weed-frame-distance", metavar="MM", type=float, default=1.0,
|
|
1208
|
+
help="Distance of outer frame from bbox in mm (default: 1)")
|
|
1209
|
+
pre_parser = argparse.ArgumentParser(add_help=False)
|
|
1210
|
+
pre_parser.add_argument('--profile', default=None)
|
|
1211
|
+
pre_parser.add_argument('--plotter', default=None)
|
|
1212
|
+
pre_args, _ = pre_parser.parse_known_args()
|
|
1213
|
+
apply_config_to_parser(parser, load_config(pre_args.profile))
|
|
1214
|
+
if pre_args.plotter:
|
|
1215
|
+
apply_config_to_parser(parser, load_plotter_config(pre_args.plotter))
|
|
1216
|
+
|
|
1217
|
+
args = parser.parse_args()
|
|
1218
|
+
|
|
1219
|
+
HPGLinput = HPGL(args.file)
|
|
1220
|
+
apply_args(HPGLinput, args)
|
|
1221
|
+
|
|
1222
|
+
if args.preview is not None:
|
|
1223
|
+
HPGLinput.exportSVG(args.preview)
|
|
1224
|
+
|
|
1225
|
+
apply_plotter_transform(HPGLinput, args)
|
|
1226
|
+
if not getattr(args, 'no_blade_prep', False):
|
|
1227
|
+
HPGLinput.bladePrepCut()
|
|
1228
|
+
|
|
1229
|
+
if args.output is not None:
|
|
1230
|
+
HPGLinput.exportHPGL(args.output)
|