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