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.
hpglpreview.py ADDED
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import copy
4
+ import math
5
+ import wx
6
+ from wx.lib.floatcanvas import FloatCanvas, GUIMode
7
+ import hpgl
8
+ import numpy
9
+
10
+ # fix broken reference to float_ in wxPython 4.2.0
11
+ FloatCanvas.float_ = numpy.float64
12
+
13
+ HPGL2MM = hpgl.hpgl2mm(1)
14
+ ZOOM_FACTOR = 1.3
15
+ PAN_STEP = 50 # pixels per cursor-key press
16
+ ARROW_SIZE = 50 # HPGL units (~1.25 mm)
17
+
18
+ _MARKER_N = 20 # polygon sides used to approximate circles
19
+
20
+
21
+ def _marker_pts(pt, radius):
22
+ return [(pt[0] + radius * math.cos(2 * math.pi * i / _MARKER_N),
23
+ pt[1] + radius * math.sin(2 * math.pi * i / _MARKER_N))
24
+ for i in range(_MARKER_N)]
25
+
26
+ def _draw_start_marker(canvas, pt, color, radius):
27
+ """Filled polygon (pen-down / start of cut)."""
28
+ canvas.AddPolygon(_marker_pts(pt, radius), LineColor=color, FillColor=color)
29
+
30
+ def _draw_end_marker(canvas, pt, color, radius):
31
+ """Circle outline polygon (pen-up / end of cut)."""
32
+ pts = _marker_pts(pt, radius)
33
+ pts.append(pts[0]) # close the loop
34
+ canvas.AddLine(pts, LineColor=color, LineWidth=2)
35
+
36
+ def _draw_arrow(canvas, path, color):
37
+ """Draw a small V-shaped direction arrowhead at the midpoint of path."""
38
+ mid = max(1, len(path) // 2)
39
+ p1, p2 = path[mid - 1], path[mid]
40
+ dx, dy = p2[0] - p1[0], p2[1] - p1[1]
41
+ ln = math.hypot(dx, dy)
42
+ if ln < ARROW_SIZE * 0.5:
43
+ return
44
+ mx, my = (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2
45
+ ux, uy = dx / ln, dy / ln
46
+ ca, sa = math.cos(math.radians(30)), math.sin(math.radians(30))
47
+ bx, by = -ux * ARROW_SIZE, -uy * ARROW_SIZE
48
+ w1 = (mx + ca * bx - sa * by, my + sa * bx + ca * by)
49
+ w2 = (mx + ca * bx + sa * by, my - sa * bx + ca * by)
50
+ canvas.AddLine([w1, (mx, my), w2], LineColor=color)
51
+
52
+
53
+ class HPGLPreview(wx.Frame):
54
+
55
+ def __init__(self, hpgldata, title="HPGL preview", size=(1600, 900), dialog=False,
56
+ show_endpoints=True, show_points=False,
57
+ pristine_hpgl=None, transform_args=None, **kwargs):
58
+ super(HPGLPreview, self).__init__(parent=None, title=title, size=size, **kwargs)
59
+ self.checked = False
60
+ self._hpgl_paths = list(hpgldata.getPaths())
61
+ self._show_endpoints = show_endpoints
62
+ self._show_points = show_points
63
+ self.CreateStatusBar()
64
+
65
+ self.sizer = wx.BoxSizer(wx.VERTICAL)
66
+
67
+ # Toolbar: zoom in/out and fit-to-view.
68
+ tb = wx.BoxSizer(wx.HORIZONTAL)
69
+ self.btn_zoom_in = wx.Button(self, label="+", size=(30, 28))
70
+ self.btn_zoom_out = wx.Button(self, label="−", size=(30, 28))
71
+ self.btn_fit = wx.Button(self, label="Fit", size=(40, 28))
72
+ for btn in (self.btn_zoom_in, self.btn_zoom_out, self.btn_fit):
73
+ tb.Add(btn, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 2)
74
+ self.sizer.Add(tb, 0, wx.ALL, 0)
75
+
76
+ # Interactive transform controls (only when a pristine copy is supplied)
77
+ self._pristine = None
78
+ if pristine_hpgl is not None:
79
+ self._pristine = pristine_hpgl
80
+ self._transform_args = transform_args
81
+ self._init_natural_size()
82
+ self._init_transform_values(transform_args)
83
+ self.sizer.Add(self._build_transform_panel(), 0, wx.ALL | wx.EXPAND, 2)
84
+
85
+ self.Canvas = FloatCanvas.FloatCanvas(self, -1, BackgroundColor="white")
86
+ self.sizer.Add(self.Canvas, 1, wx.ALL | wx.EXPAND)
87
+
88
+ # Summary bar
89
+ self.summary_label = wx.StaticText(self, label=self._make_summary(hpgldata),
90
+ style=wx.ALIGN_CENTER)
91
+ self.sizer.Add(self.summary_label, 0, wx.ALL | wx.EXPAND, 4)
92
+
93
+ hpgl_row = wx.BoxSizer(wx.HORIZONTAL)
94
+ hpgl_row.Add(wx.StaticText(self, label="Path HPGL:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4)
95
+ self.hpgl_field = wx.TextCtrl(self, style=wx.TE_READONLY | wx.TE_PROCESS_ENTER, size=(-1, -1))
96
+ hpgl_row.Add(self.hpgl_field, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 4)
97
+ self.sizer.Add(hpgl_row, 0, wx.ALL | wx.EXPAND, 2)
98
+
99
+ self.bsizer = wx.BoxSizer(wx.HORIZONTAL)
100
+ if dialog:
101
+ self.btn_ok = wx.Button(self, wx.ID_OK, label="OK")
102
+ self.btn_cancel = wx.Button(self, wx.ID_CANCEL, label="Cancel")
103
+ self.bsizer.AddStretchSpacer(1)
104
+ self.bsizer.Add(self.btn_ok, 0, wx.EXPAND | wx.RIGHT, 5)
105
+ self.bsizer.Add(self.btn_cancel, 0, wx.EXPAND | wx.LEFT, 5)
106
+ self.btn_ok.Bind(wx.EVT_BUTTON, self.OnOK)
107
+ self.btn_cancel.Bind(wx.EVT_BUTTON, self.OnCancel)
108
+ self.bsizer.AddStretchSpacer(1)
109
+ self.sizer.Add(self.bsizer, 0, wx.ALL | wx.EXPAND, 2)
110
+
111
+ self.SetSizer(self.sizer)
112
+
113
+ self._travel_pen = wx.Pen("blue", 1, wx.PENSTYLE_USER_DASH)
114
+ self._travel_pen.SetDashes([2, 12])
115
+ self._draw_canvas(hpgldata)
116
+
117
+ self.Canvas.SetMode(GUIMode.GUIMove())
118
+ self.btn_zoom_in.Bind(wx.EVT_BUTTON, lambda e: self.Canvas.Zoom(ZOOM_FACTOR))
119
+ self.btn_zoom_out.Bind(wx.EVT_BUTTON, lambda e: self.Canvas.Zoom(1.0 / ZOOM_FACTOR))
120
+ self.btn_fit.Bind(wx.EVT_BUTTON, lambda e: self.Canvas.ZoomToBB())
121
+ self.Canvas.Bind(wx.EVT_MOTION, self.OnMove)
122
+ self.Canvas.Bind(wx.EVT_RIGHT_DOWN, self.OnCanvasRightClick)
123
+ self.Bind(wx.EVT_CLOSE, self.OnClose)
124
+ self.Bind(wx.EVT_CHAR_HOOK, self.OnKeyDown)
125
+
126
+ # ── interactive transform controls ────────────────────────────────────
127
+
128
+ def _init_natural_size(self):
129
+ tmp = copy.deepcopy(self._pristine)
130
+ tmp.fit()
131
+ self._natural_w, self._natural_h = tmp.getSize()
132
+
133
+ def _init_transform_values(self, args):
134
+ if args is None or self._natural_w == 0:
135
+ self._tf_width = self._natural_w
136
+ elif getattr(args, 'width', None) is not None:
137
+ self._tf_width = args.width
138
+ elif getattr(args, 'scale', None) is not None:
139
+ self._tf_width = self._natural_w * args.scale
140
+ elif getattr(args, 'height', None) is not None and self._natural_h > 0:
141
+ self._tf_width = self._natural_w * args.height / self._natural_h
142
+ else:
143
+ self._tf_width = self._natural_w
144
+ self._tf_rotate = float(getattr(args, 'rotate', None) or 0.0)
145
+ self._tf_mirror = bool(getattr(args, 'mirror', False))
146
+ self._tf_flip = bool(getattr(args, 'flip', False))
147
+
148
+ def _build_transform_panel(self):
149
+ row = wx.BoxSizer(wx.HORIZONTAL)
150
+
151
+ row.Add(wx.StaticText(self, label="Width:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4)
152
+ self.spin_width = wx.SpinCtrlDouble(self, min=0.1, max=10000.0, inc=0.5,
153
+ style=wx.SP_ARROW_KEYS)
154
+ self.spin_width.SetDigits(1)
155
+ self.spin_width.SetValue(self._tf_width)
156
+ row.Add(self.spin_width, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 2)
157
+ row.Add(wx.StaticText(self, label="mm"), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 2)
158
+
159
+ init_h = (self._natural_h * self._tf_width / self._natural_w
160
+ if self._natural_w > 0 else self._natural_h)
161
+ self.lbl_height = wx.StaticText(self, label="H: {:.1f} mm".format(init_h))
162
+ row.Add(self.lbl_height, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8)
163
+
164
+ row.Add(wx.StaticText(self, label="Rotate:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 20)
165
+ self.spin_rotate = wx.SpinCtrlDouble(self, min=-360.0, max=360.0, inc=1.0)
166
+ self.spin_rotate.SetDigits(1)
167
+ self.spin_rotate.SetValue(self._tf_rotate)
168
+ row.Add(self.spin_rotate, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 2)
169
+ row.Add(wx.StaticText(self, label="°"), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 2)
170
+
171
+ self.chk_mirror = wx.CheckBox(self, label="Mirror")
172
+ self.chk_mirror.SetValue(self._tf_mirror)
173
+ row.Add(self.chk_mirror, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 20)
174
+
175
+ self.chk_flip = wx.CheckBox(self, label="Flip")
176
+ self.chk_flip.SetValue(self._tf_flip)
177
+ row.Add(self.chk_flip, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8)
178
+
179
+ self.spin_width.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_transform_change)
180
+ self.spin_rotate.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_transform_change)
181
+ self.chk_mirror.Bind(wx.EVT_CHECKBOX, self._on_transform_change)
182
+ self.chk_flip.Bind(wx.EVT_CHECKBOX, self._on_transform_change)
183
+
184
+ return row
185
+
186
+ def _on_transform_change(self, _event):
187
+ self._redraw()
188
+
189
+ def _redraw(self):
190
+ width = self.spin_width.GetValue()
191
+ rotate = self.spin_rotate.GetValue()
192
+ mirror = self.chk_mirror.GetValue()
193
+ flip = self.chk_flip.GetValue()
194
+
195
+ hpgldata = copy.deepcopy(self._pristine)
196
+ if width > 0 and self._natural_w > 0:
197
+ hpgldata.scaleToWidth(width)
198
+ if rotate % 360 != 0:
199
+ hpgldata.rotate(rotate)
200
+ if mirror:
201
+ hpgldata.mirrorX()
202
+ if flip:
203
+ hpgldata.mirrorY()
204
+
205
+ _w, h = hpgldata.getSize()
206
+ self.lbl_height.SetLabel("H: {:.1f} mm".format(h))
207
+ self.summary_label.SetLabel(self._make_summary(hpgldata))
208
+ self._hpgl_paths = list(hpgldata.getPaths())
209
+
210
+ self.Canvas.ClearAll()
211
+ self._draw_canvas(hpgldata)
212
+ self.Canvas.Draw(True)
213
+
214
+ @property
215
+ def final_transform(self):
216
+ """Return the transform values chosen by the user, or None if no controls."""
217
+ if self._pristine is None:
218
+ return None
219
+ return {
220
+ 'width': getattr(self, '_final_width', self._tf_width),
221
+ 'rotate': getattr(self, '_final_rotate', self._tf_rotate),
222
+ 'mirror': getattr(self, '_final_mirror', self._tf_mirror),
223
+ 'flip': getattr(self, '_final_flip', self._tf_flip),
224
+ }
225
+
226
+ # ── canvas drawing ────────────────────────────────────────────────────
227
+
228
+ @staticmethod
229
+ def _make_summary(hpgldata):
230
+ w, h = hpgldata.getSize()
231
+ travel, draw = hpgldata.getLength()
232
+ n_paths = len(hpgldata.getPaths())
233
+ return ("Size: {:.1f} × {:.1f} mm | Area: {:.1f} cm² | "
234
+ "Paths: {} | Cut: {:.1f} cm | Travel: {:.1f} cm"
235
+ ).format(w, h, w / 10 * h / 10, n_paths, draw / 10, travel / 10)
236
+
237
+ def _draw_canvas(self, hpgldata):
238
+ last = (0, 0)
239
+ for line in hpgldata.getPaths():
240
+ self.Canvas.AddLine(line)
241
+ _draw_arrow(self.Canvas, line, "black")
242
+ if self._show_points:
243
+ for pt in line:
244
+ _draw_start_marker(self.Canvas, pt, "LightBlue", ARROW_SIZE * 0.35)
245
+ travel = [last, line[0]]
246
+ travel_obj = self.Canvas.AddLine(travel, LineColor="blue")
247
+ travel_obj.Pen = self._travel_pen
248
+ _draw_arrow(self.Canvas, travel, "blue")
249
+ if self._show_endpoints:
250
+ _draw_start_marker(self.Canvas, line[0], "SteelBlue", ARROW_SIZE * 0.6)
251
+ _draw_end_marker( self.Canvas, line[-1], "DarkBlue", ARROW_SIZE)
252
+ last = line[-1]
253
+ home = [last, (0, 0)]
254
+ self.Canvas.AddLine(home, LineColor="green")
255
+ _draw_arrow(self.Canvas, home, "green")
256
+ m, mm = hpgldata.getBoundingBox()
257
+ self.Canvas.AddRectangle((0, 0), (mm[0] + m[0], mm[1] + m[1]), LineColor="orange")
258
+ if hasattr(hpgldata, '_design_bbox'):
259
+ dm, dmm = hpgldata._design_bbox
260
+ self.Canvas.AddRectangle((dm[0], dm[1]), (dmm[0] - dm[0], dmm[1] - dm[1]),
261
+ LineColor="green")
262
+
263
+ # ── event handlers ────────────────────────────────────────────────────
264
+
265
+ def _segment_dist(self, px, py, ax, ay, bx, by):
266
+ """Minimum distance from point (px,py) to segment (ax,ay)-(bx,by)."""
267
+ dx, dy = bx - ax, by - ay
268
+ if dx == 0 and dy == 0:
269
+ return math.hypot(px - ax, py - ay)
270
+ t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
271
+ return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
272
+
273
+ def _nearest_path_index(self, wx_coord, wy_coord):
274
+ best_idx, best_dist = 0, float('inf')
275
+ for i, path in enumerate(self._hpgl_paths):
276
+ for j in range(len(path) - 1):
277
+ d = self._segment_dist(wx_coord, wy_coord,
278
+ path[j][0], path[j][1],
279
+ path[j + 1][0], path[j + 1][1])
280
+ if d < best_dist:
281
+ best_dist, best_idx = d, i
282
+ return best_idx
283
+
284
+ @staticmethod
285
+ def _path_to_hpgl(path):
286
+ first = path[0]
287
+ rest = path[1:]
288
+ coords = ",".join("{:d},{:d}".format(int(round(x)), int(round(y))) for x, y in rest)
289
+ return "PU{:d},{:d};PD{};".format(int(round(first[0])), int(round(first[1])), coords)
290
+
291
+ def OnCanvasRightClick(self, event):
292
+ world = self.Canvas.PixelToWorld(event.GetPosition())
293
+ idx = self._nearest_path_index(world[0], world[1])
294
+ self.hpgl_field.SetValue(self._path_to_hpgl(self._hpgl_paths[idx]))
295
+ self.hpgl_field.SelectAll()
296
+ event.Skip()
297
+
298
+ def OnKeyDown(self, event):
299
+ key = event.GetKeyCode()
300
+ if key in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER):
301
+ self.OnOK(event)
302
+ elif key == wx.WXK_ESCAPE:
303
+ self.OnCancel(event)
304
+ elif key in (ord('+'), ord('='), wx.WXK_NUMPAD_ADD):
305
+ self.Canvas.Zoom(ZOOM_FACTOR)
306
+ elif key in (ord('-'), wx.WXK_NUMPAD_SUBTRACT):
307
+ self.Canvas.Zoom(1.0 / ZOOM_FACTOR)
308
+ elif key in (ord('f'), ord('F')):
309
+ self.Canvas.ZoomToBB()
310
+ elif key == wx.WXK_LEFT:
311
+ self.Canvas.MoveImage((-PAN_STEP, 0), 'Pixel')
312
+ elif key == wx.WXK_RIGHT:
313
+ self.Canvas.MoveImage((PAN_STEP, 0), 'Pixel')
314
+ elif key == wx.WXK_UP:
315
+ self.Canvas.MoveImage((0, -PAN_STEP), 'Pixel')
316
+ elif key == wx.WXK_DOWN:
317
+ self.Canvas.MoveImage((0, PAN_STEP), 'Pixel')
318
+ else:
319
+ event.Skip()
320
+
321
+ def OnOK(self, event):
322
+ self.checked = True
323
+ if self._pristine is not None:
324
+ self._final_width = self.spin_width.GetValue()
325
+ self._final_rotate = self.spin_rotate.GetValue()
326
+ self._final_mirror = self.chk_mirror.GetValue()
327
+ self._final_flip = self.chk_flip.GetValue()
328
+ self.Close()
329
+
330
+ def OnCancel(self, event):
331
+ self.Close()
332
+
333
+ def OnMove(self, event):
334
+ coords = self.Canvas.PixelToWorld(event.GetPosition())
335
+ self.SetStatusText("%.2f mm, %.2f mm" % (coords[0] * HPGL2MM, coords[1] * HPGL2MM))
336
+ event.Skip()
337
+
338
+ def OnClose(self, event):
339
+ self.eventLoop.Exit()
340
+
341
+ def ShowModal(self):
342
+ if hasattr(self, "MakeModal"):
343
+ self.MakeModal() # removed in wxPython 4.x; present on older versions
344
+ self.Show()
345
+ self.Canvas.ZoomToBB()
346
+
347
+ self.eventLoop = wx.GUIEventLoop()
348
+ self.eventLoop.Run()
349
+ self.Destroy()
350
+ return self.checked
351
+
352
+
353
+ if __name__ == "__main__":
354
+ import argparse
355
+ app = wx.App(False)
356
+ parser = argparse.ArgumentParser("HPGL preview")
357
+ parser.add_argument("file", type=str, help="the HPGL-file to open")
358
+ weed_group = parser.add_argument_group("weeding lines")
359
+ weed_group.add_argument("--weed", metavar="STRATEGY",
360
+ choices=["grid", "horizontal", "vertical", "frame",
361
+ "diagonal", "rombic", "tick", "radial"],
362
+ help="Add weeding lines")
363
+ weed_group.add_argument("--weed-size", metavar="PCT", type=float, default=25.0)
364
+ weed_group.add_argument("--weed-small-size", metavar="PCT", type=float, default=0.0)
365
+ weed_group.add_argument("--weed-min-size", metavar="PCT", type=float, default=0.0)
366
+ weed_group.add_argument("--weed-min-x", metavar="MM", type=float, default=1.0)
367
+ weed_group.add_argument("--weed-max-x", metavar="MM", type=float, default=None)
368
+ weed_group.add_argument("--weed-min-y", metavar="MM", type=float, default=1.0)
369
+ weed_group.add_argument("--weed-max-y", metavar="MM", type=float, default=None)
370
+ weed_group.add_argument("--weed-margin", metavar="MM", type=float, default=2.0)
371
+ weed_group.add_argument("--weed-tick-length", metavar="MM", type=float, default=5.0)
372
+ weed_group.add_argument("--no-weed-adaptive", action="store_true")
373
+ weed_group.add_argument("--no-weed-frame", action="store_true")
374
+ weed_group.add_argument("--weed-frame-distance", metavar="MM", type=float, default=1.0)
375
+ args = parser.parse_args()
376
+
377
+ hpglfile = hpgl.HPGL(args.file)
378
+ pristine = copy.deepcopy(hpglfile)
379
+ hpgl.apply_args(hpglfile, args)
380
+
381
+ dialog = HPGLPreview(hpglfile, pristine_hpgl=pristine)
382
+ dialog.ShowModal()
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: plottool
3
+ Version: 0.1.0
4
+ Summary: HPGL plotter/cutter tool with serial port support, path optimization, blade offset compensation, preview, and weeding lines
5
+ Author-email: Till Uhde <till@uhde.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Utilities
10
+ Requires-Python: >=3.7
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: pyserial
14
+ Requires-Dist: numpy
15
+ Requires-Dist: wxPython
16
+ Dynamic: license-file
17
+
18
+ plottool
19
+ ========
20
+
21
+ A tool for sending HPGL files to a plotter or cutting plotter connected over a
22
+ serial port. Includes path optimization, blade offset compensation, scaling,
23
+ rotation, tiling, weeding lines, config profiles, and a graphical preview window.
24
+
25
+ Tested with a Cogi CT-630 cutting plotter @ Stratum0:
26
+ https://stratum0.org/wiki/Cogi_CT-630
27
+
28
+ Also works on macOS, but less reliably — prepare for occasional job cancellation.
29
+
30
+ > Forked from [stratum0/plottool](https://github.com/stratum0/plottool).
31
+
32
+ > This project is being actively extended with the help of
33
+ > [Claude Code](https://claude.ai/code) (Anthropic AI).
34
+
35
+
36
+ Dependencies
37
+ ------------
38
+
39
+ For Debian-based Linux distributions (Ubuntu, Mint):
40
+
41
+ ```
42
+ sudo apt-get install python3-serial python3-wxgtk4.0 python3-numpy
43
+ ```
44
+
45
+ For Arch Linux:
46
+
47
+ ```
48
+ sudo pacman -S python-numpy python-pyserial python-wxpython
49
+ ```
50
+
51
+ For macOS (homebrew + pip):
52
+
53
+ ```
54
+ pip install numpy pyserial && brew install wxpython
55
+ ```
56
+
57
+ Or install via pip from the repository root:
58
+
59
+ ```
60
+ pip install .
61
+ ```
62
+
63
+
64
+ Usage — plottool.py
65
+ --------------------
66
+
67
+ Send an HPGL file to the plotter:
68
+
69
+ ```
70
+ ./plottool.py file.hpgl
71
+ ./plottool.py file.hpgl -m --width 200
72
+ ./plottool.py file.hpgl --profile vinyl
73
+ ```
74
+
75
+ **Options:**
76
+
77
+ | Flag | Default | Description |
78
+ |------|---------|-------------|
79
+ | `--profile NAME` | — | Load a config profile (see Config file) |
80
+ | `-p`, `--port PORT` | `/dev/ttyUSB0` | Serial port |
81
+ | `-b`, `--baud BAUD` | `9600` | Serial baud rate |
82
+ | `-m`, `--magic` | off | Auto-optimize: point reduction + blade offset + reroute |
83
+ | `-w`, `--width MM` | — | Scale to width in mm (mutually exclusive with `-H`, `-s`) |
84
+ | `-H`, `--height MM` | — | Scale to height in mm (mutually exclusive with `-w`, `-s`) |
85
+ | `-s`, `--scale FACTOR` | — | Scale by factor, e.g. `0.5` for half size (mutually exclusive with `-w`, `-H`) |
86
+ | `--mirror` | off | Flip left-right (for inverted cuts, e.g. T-shirts) |
87
+ | `--flip` | off | Flip top-bottom |
88
+ | `--rotate DEG` | — | Rotate counter-clockwise by DEG degrees (uses axis swaps at multiples of 90°) |
89
+ | `-v`, `--preview` | off | Show preview window before plotting |
90
+ | `-o`, `--output FILE` | — | Save processed HPGL to FILE before plotting |
91
+ | `--pen` | off | Disable blade offset compensation (use for pen plotters) |
92
+ | `--blade-offset MM` | `0.25` | Blade trailing offset in mm (ignored with `--pen`) |
93
+ | `--no-blade-prep` | off | Skip the 2 mm seating cut at origin |
94
+ | `--reroute {xy,nearest,none}` | `xy` | Path order: `xy` = boustrophedon rows, `nearest` = greedy nearest-neighbour, `none` = original |
95
+ | `--repeat-x N` | `1` | Tile the design N times along X |
96
+ | `--repeat-y N` | `1` | Tile the design N times along Y |
97
+ | `--gap MM` | `5` | Gap between tiles (both axes) |
98
+ | `--gap-x MM` | — | Gap along X, overrides `--gap` (negative = overlap) |
99
+ | `--gap-y MM` | — | Gap along Y, overrides `--gap` (negative = overlap) |
100
+ | `--offset-x MM` | `0` | X offset per Y-step (stagger rows) |
101
+ | `--offset-y MM` | `0` | Y offset per X-step (stagger columns) |
102
+ | `--weed STRATEGY` | — | Add weeding lines (see Weeding lines) |
103
+ | `--weed-size PCT` | `25` | Max waste piece size as % of bbox area |
104
+ | `--weed-min-x MM` | `1` | Min spacing between vertical weeding lines |
105
+ | `--weed-max-x MM` | — | Max spacing between vertical weeding lines |
106
+ | `--weed-min-y MM` | `1` | Min spacing between horizontal weeding lines |
107
+ | `--weed-max-y MM` | — | Max spacing between horizontal weeding lines |
108
+ | `--weed-margin MM` | `2` | Extend weeding lines beyond bbox |
109
+ | `--weed-tick-length MM` | `5` | Tick/comb tooth length |
110
+ | `--weed-small-size PCT` | auto | Radial inner circle area (default: weed-size/10) |
111
+ | `--weed-min-size PCT` | auto | Drop lines creating waste pieces smaller than this |
112
+ | `--weed-frame-distance MM` | `1` | Distance of outer frame from bbox |
113
+ | `--no-weed-frame` | — | Suppress the automatic outer frame |
114
+ | `--no-weed-adaptive` | — | Disable adaptive clipping at design intersections |
115
+
116
+ On macOS, serial devices follow a different naming convention — look for something
117
+ like `/dev/tty.usbserial-14430` or `/dev/cu.usbserial-14430`.
118
+
119
+
120
+ Usage — hpgl.py
121
+ ----------------
122
+
123
+ Process an HPGL file without plotting (convert, optimise, preview, export):
124
+
125
+ ```
126
+ ./hpgl.py file.hpgl -o out.hpgl -p preview.svg
127
+ ./hpgl.py file.hpgl -m --rotate 90 -o out.hpgl
128
+ ```
129
+
130
+ Supports all flags from `plottool.py` except `--port`, `--baud`, and `--preview`
131
+ (which is `-p SVG` here, exporting to an SVG file instead of opening a window).
132
+
133
+
134
+ Processing pipeline
135
+ -------------------
136
+
137
+ When multiple options are active, they are applied in this order:
138
+
139
+ 1. **Scale** (`--width` / `--height` / `--scale`)
140
+ 2. **Rotate** (`--rotate`)
141
+ 3. **Mirror** (`--mirror`) — flip left-right
142
+ 4. **Flip** (`--flip`) — flip top-bottom
143
+ 5. **Optimize** (`--magic`) — remove redundant points, fit to origin
144
+ 6. **Blade offset** (`--magic`) — overshoot correction at corners
145
+ 7. **Tile** (`--repeat-x` / `--repeat-y`)
146
+ 8. **Weeding lines** (`--weed`)
147
+ 9. **Reroute** (`--reroute`) — reorder paths for travel efficiency
148
+ 10. **Blade prep cut** — 2 mm seating cut prepended at origin (disable with `--no-blade-prep`)
149
+
150
+
151
+ Weeding lines
152
+ -------------
153
+
154
+ Add cut lines to help remove waste vinyl after cutting. Select a strategy with
155
+ `--weed STRATEGY`:
156
+
157
+ | Strategy | Description |
158
+ |----------|-------------|
159
+ | `grid` | Horizontal + vertical lines across the bbox |
160
+ | `horizontal` | Horizontal lines only |
161
+ | `vertical` | Vertical lines only |
162
+ | `diagonal` | 45° lines across the bbox |
163
+ | `rombic` | Both diagonal families (diamond grid) |
164
+ | `frame` | Concentric equal-area rectangles stepping inward |
165
+ | `tick` | Short inward comb-teeth from each bbox edge |
166
+ | `radial` | Evenly-angled spokes from the bbox centre |
167
+
168
+ Line density is controlled by `--weed-size PCT` (max waste piece size as % of bbox
169
+ area, default 25). All strategies support **adaptive clipping** (default on): weeding
170
+ lines are split at design intersections so only waste-area segments are kept. An outer
171
+ frame rectangle (1 mm from bbox by default) is added automatically.
172
+
173
+ See [WEEDING_LINES.md](WEEDING_LINES.md) for full parameter details.
174
+
175
+
176
+ Config file
177
+ -----------
178
+
179
+ Settings can be stored in `~/.plottoolrc` (global) or `plottool.conf` in the current
180
+ directory (local, takes precedence). The format is INI:
181
+
182
+ ```ini
183
+ [default]
184
+ magic = true
185
+ blade-offset = 0.3
186
+
187
+ [vinyl]
188
+ width = 300
189
+ reroute = nearest
190
+
191
+ [shirt]
192
+ mirror = true
193
+ width = 300
194
+
195
+ [pen]
196
+ pen = true
197
+ no-blade-prep = true
198
+ ```
199
+
200
+ The `[default]` section always applies. Use `--profile NAME` to also apply a named
201
+ section on top. Command-line arguments always override config values. See
202
+ `plottool.conf.example` for a full annotated example.
203
+
204
+
205
+ Preview window
206
+ --------------
207
+
208
+ The preview window (`-v` in `plottool.py`, `-p SVG` exports to file in `hpgl.py`)
209
+ shows the processed paths with:
210
+
211
+ - Direction arrows on cut paths, travel moves, and the return-to-origin line
212
+ - Dotted travel (pen-up) lines
213
+ - Start-of-cut (filled blue dot) and end-of-cut (blue ring) markers at every pen transition
214
+ - The design bounding box before weeding lines are added (green rectangle)
215
+
216
+ Interactive controls (plottool.py preview window):
217
+
218
+ | Input | Action |
219
+ |-------|--------|
220
+ | Left-drag | Pan |
221
+ | Scroll wheel | Zoom (centred on cursor) |
222
+ | `+` / `=` | Zoom in |
223
+ | `-` | Zoom out |
224
+ | `f` | Fit view |
225
+ | Arrow keys | Pan |
226
+ | Enter | Confirm and proceed to plot |
227
+ | Esc | Cancel |
@@ -0,0 +1,9 @@
1
+ hpgl.py,sha256=IOMdExu_pun1XKtXRUkF3Ww_MunakTfrQ7o8mh6HX84,49300
2
+ hpglpreview.py,sha256=AKUZyTMLE_rGQmpinxP92rOgGOh9QW_0An6PTGIXCvs,16927
3
+ plottool.py,sha256=52vMFLUStjMURLU7OCnjQuuKMVWnw5mEWSa4HabwZZo,10587
4
+ plottool-0.1.0.dist-info/licenses/LICENSE,sha256=SHi4lLkD-yJ56xZkQ_8aPzzKOS7d8Fm5hknIYjVoLF0,1066
5
+ plottool-0.1.0.dist-info/METADATA,sha256=MvlnW8spNiRi0zZdWX4MjAMMSubVTfYvPeiO3cjDajA,7975
6
+ plottool-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ plottool-0.1.0.dist-info/entry_points.txt,sha256=Yk_qGcVaEtzmWTavsML-DPqqWx7FAX9ly-1nnnUtxZM,43
8
+ plottool-0.1.0.dist-info/top_level.txt,sha256=6rK-hScTXOIe55Sz_HT1aQGNByrbEMP-Nz9hlFENXTU,26
9
+ plottool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plottool = plottool:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Till Uhde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ hpgl
2
+ hpglpreview
3
+ plottool