gantrix 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.
gantrix/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from gantrix.job import Job
2
+ from gantrix.transport import GrblTransport
3
+
4
+ __all__ = ["Job", "GrblTransport"]
5
+ __version__ = "0.1.0"
gantrix/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from gantrix.design.text import layout_text
6
+ from gantrix.job import Job
7
+ from gantrix.tools.laser import LaserTool
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="gantrix", description="Drive a GRBL gantry.")
12
+ sub = parser.add_subparsers(dest="command", required=True)
13
+
14
+ text_cmd = sub.add_parser("text", help="Engrave text using the built-in stroke font.")
15
+ text_cmd.add_argument("text")
16
+ text_cmd.add_argument("--port", required=True)
17
+ text_cmd.add_argument("--power", type=int, default=600)
18
+ text_cmd.add_argument("--speed", type=int, default=500)
19
+ text_cmd.add_argument("--line-gap", type=float, default=0.25)
20
+ text_cmd.add_argument("--height", type=float, default=20.0)
21
+
22
+ qr_cmd = sub.add_parser("qr", help="Engrave a QR code.")
23
+ qr_cmd.add_argument("data")
24
+ qr_cmd.add_argument("--port", required=True)
25
+ qr_cmd.add_argument("--power", type=int, default=600)
26
+ qr_cmd.add_argument("--speed", type=int, default=500)
27
+ qr_cmd.add_argument("--cell-size", type=float, default=1.5, help="mm per QR module")
28
+
29
+ image_cmd = sub.add_parser("image", help="Engrave a raster image (logo, silhouette, photo).")
30
+ image_cmd.add_argument("path")
31
+ image_cmd.add_argument("--port", required=True)
32
+ image_cmd.add_argument("--power", type=int, default=600)
33
+ image_cmd.add_argument("--speed", type=int, default=500)
34
+ image_cmd.add_argument("--height", type=float, default=40.0)
35
+ image_cmd.add_argument("--cell-size", type=float, default=0.3, help="mm per pixel (resolution)")
36
+ image_cmd.add_argument("--threshold", type=int, default=128)
37
+ image_cmd.add_argument("--invert", action="store_true")
38
+
39
+ args = parser.parse_args()
40
+
41
+ if args.command == "text":
42
+ placed, total_w = layout_text(args.text.upper())
43
+ print(f"{args.text!r} — {total_w:.0f}mm wide x {args.height:.0f}mm tall")
44
+ tool = LaserTool(power=args.power, speed=args.speed)
45
+ Job(args.port, tool, placed, height=args.height, line_gap=args.line_gap).run()
46
+
47
+ elif args.command == "qr":
48
+ from gantrix.design.qrcode import layout_qr
49
+
50
+ placed, w, h, line_gap = layout_qr(args.data, cell_size=args.cell_size)
51
+ print(f"QR {args.data!r} — {w:.0f}mm x {h:.0f}mm, {args.cell_size}mm/module")
52
+ tool = LaserTool(power=args.power, speed=args.speed)
53
+ Job(args.port, tool, placed, height=h, line_gap=line_gap).run()
54
+
55
+ elif args.command == "image":
56
+ from gantrix.design.image import layout_image
57
+
58
+ placed, w, h = layout_image(
59
+ args.path, args.height, cell_size=args.cell_size,
60
+ threshold=args.threshold, invert=args.invert,
61
+ )
62
+ print(f"{args.path} — {w:.0f}mm x {h:.0f}mm, {args.cell_size}mm/pixel")
63
+ tool = LaserTool(power=args.power, speed=args.speed)
64
+ Job(args.port, tool, placed, height=h, line_gap=args.cell_size).run()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
File without changes
@@ -0,0 +1,51 @@
1
+ """Arbitrary raster images (logos, photos, silhouettes) as engravable designs.
2
+
3
+ Downsamples to one pixel per `cell_size` mm, thresholds to black/white, and
4
+ exposes the result as a RasterShape — same fill-planner path as text and QR.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from PIL import Image
9
+
10
+ from gantrix.design.shapes import RasterShape
11
+ from gantrix.planner.fill import Placed
12
+
13
+
14
+ def image_shape(
15
+ path: str,
16
+ height_mm: float,
17
+ cell_size: float = 0.3,
18
+ threshold: int = 128,
19
+ invert: bool = False,
20
+ ) -> RasterShape:
21
+ """Load an image and reduce it to a boolean fill grid.
22
+
23
+ `height_mm` is the desired physical output height; `cell_size` (mm) sets
24
+ the resolution (pixels per engraved dot) — smaller means finer detail
25
+ and more scanlines. Width is derived from the image's aspect ratio.
26
+ Pixels darker than `threshold` (0-255) are treated as fill; pass
27
+ `invert=True` if your source has a light subject on a dark background.
28
+ """
29
+ rows = max(1, round(height_mm / cell_size))
30
+ img = Image.open(path).convert("L")
31
+ cols = max(1, round(rows * img.width / img.height))
32
+ img = img.resize((cols, rows), Image.LANCZOS)
33
+
34
+ pixels = img.load()
35
+ grid = [
36
+ [(pixels[c, r] < threshold) != invert for c in range(cols)]
37
+ for r in range(rows)
38
+ ]
39
+ return RasterShape(grid, cell_size)
40
+
41
+
42
+ def layout_image(
43
+ path: str,
44
+ height_mm: float,
45
+ cell_size: float = 0.3,
46
+ threshold: int = 128,
47
+ invert: bool = False,
48
+ ) -> tuple[list[Placed], float, float]:
49
+ """Convenience wrapper: returns (placed, total_width, total_height)."""
50
+ shape = image_shape(path, height_mm, cell_size, threshold, invert)
51
+ return [Placed(shape, x=0.0)], shape.width, shape.height
@@ -0,0 +1,29 @@
1
+ """QR codes as an engravable design: encode data, render as a RasterShape."""
2
+ from __future__ import annotations
3
+
4
+ import qrcode as _qrcode
5
+
6
+ from gantrix.design.shapes import RasterShape
7
+ from gantrix.planner.fill import Placed
8
+
9
+
10
+ def qr_shape(data: str, cell_size: float = 1.5, border: int = 4) -> tuple[RasterShape, float]:
11
+ """Build a QR code shape for `data`.
12
+
13
+ `cell_size` is the physical size (mm) of one QR module — this should
14
+ also be used as the fill planner's `line_gap` so each module row gets
15
+ exactly one scanline pass (returned as the second element).
16
+ `border` is the quiet-zone width in modules (4 is the QR spec minimum).
17
+ """
18
+ qr = _qrcode.QRCode(border=border, box_size=1)
19
+ qr.add_data(data)
20
+ qr.make(fit=True)
21
+ grid = qr.get_matrix() # list[list[bool]], already includes the border
22
+ shape = RasterShape(grid, cell_size)
23
+ return shape, cell_size
24
+
25
+
26
+ def layout_qr(data: str, cell_size: float = 1.5, border: int = 4) -> tuple[list[Placed], float, float, float]:
27
+ """Convenience wrapper: returns (placed, total_width, total_height, recommended_line_gap)."""
28
+ shape, line_gap = qr_shape(data, cell_size, border)
29
+ return [Placed(shape, x=0.0)], shape.width, shape.height, line_gap
@@ -0,0 +1,39 @@
1
+ """Generic bitmap-grid Shape: a design as a grid of filled/empty cells.
2
+
3
+ QR codes and thresholded raster images are both "a grid of dark cells at
4
+ some physical cell size" — this one class backs both, each row of the
5
+ grid becoming one scanline's worth of fill spans.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ class RasterShape:
11
+ def __init__(self, grid: list[list[bool]], cell_size: float):
12
+ assert grid, "grid must have at least one row"
13
+ self.grid = grid
14
+ self.cell_size = cell_size
15
+ self.n_rows = len(grid)
16
+ self.n_cols = len(grid[0])
17
+ self.height = self.n_rows * cell_size
18
+ self.width = self.n_cols * cell_size
19
+
20
+ def spans_at_y(self, y: float) -> list[tuple[float, float]]:
21
+ if not (-0.001 <= y <= self.height + 0.001):
22
+ return []
23
+ # local y is bottom-up (0 = bottom, matches the fill planner's
24
+ # convention); grid row 0 is the top row, so flip.
25
+ row_idx = self.n_rows - 1 - int(min(y, self.height - 1e-9) // self.cell_size)
26
+ row_idx = max(0, min(self.n_rows - 1, row_idx))
27
+ row = self.grid[row_idx]
28
+
29
+ spans: list[tuple[float, float]] = []
30
+ start = None
31
+ for col, filled in enumerate(row):
32
+ if filled and start is None:
33
+ start = col
34
+ elif not filled and start is not None:
35
+ spans.append((start * self.cell_size, col * self.cell_size))
36
+ start = None
37
+ if start is not None:
38
+ spans.append((start * self.cell_size, self.n_cols * self.cell_size))
39
+ return spans
gantrix/design/text.py ADDED
@@ -0,0 +1,93 @@
1
+ """Simple stroke-font text as a fill design source.
2
+
3
+ Each letter is defined as rectangles plus tapered diagonals; this is the
4
+ same scanline math the original one-off engraving script used, now
5
+ implementing the `Shape` protocol so it plugs into the generic fill planner.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from gantrix.planner.fill import Placed
12
+
13
+ H = 20.0 # default letter height (mm)
14
+ S = 3.0 # default stroke thickness (mm)
15
+ W = 12.0 # default standard letter width (mm)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Letter:
20
+ width: float
21
+ rects: list[tuple[float, float, float, float]] = field(default_factory=list)
22
+ diags: list[dict] = field(default_factory=list)
23
+
24
+ def spans_at_y(self, y: float) -> list[tuple[float, float]]:
25
+ raw = []
26
+ for x1, y1, x2, y2 in self.rects:
27
+ if y1 - 0.001 <= y <= y2 + 0.001:
28
+ raw.append((min(x1, x2), max(x1, x2)))
29
+ for d in self.diags:
30
+ yt, yb = d.get("yt", H), d.get("yb", 0)
31
+ if not (yb - 0.001 <= y <= yt + 0.001):
32
+ continue
33
+ span = yt - yb
34
+ t = (y - yb) / span if span > 0 else 1.0
35
+ xc = d["xb"] + (d["xt"] - d["xb"]) * t
36
+ raw.append((max(0, xc - d["hw"]), min(self.width, xc + d["hw"])))
37
+ if not raw:
38
+ return []
39
+ raw.sort()
40
+ merged = [list(raw[0])]
41
+ for x1, x2 in raw[1:]:
42
+ if x1 <= merged[-1][1] + 0.001:
43
+ merged[-1][1] = max(merged[-1][1], x2)
44
+ else:
45
+ merged.append([x1, x2])
46
+ return [(a, b) for a, b in merged]
47
+
48
+
49
+ FONT: dict[str, Letter] = {
50
+ "M": Letter(15.0, rects=[(0, 0, S, H), (15 - S, 0, 15, H)], diags=[
51
+ {"xt": S / 2, "xb": 7.5, "hw": S / 2, "yt": H, "yb": H / 2},
52
+ {"xt": 15 - S / 2, "xb": 7.5, "hw": S / 2, "yt": H, "yb": H / 2},
53
+ ]),
54
+ "A": Letter(W, rects=[(0, H * 0.32 - S / 2, W, H * 0.32 + S / 2)], diags=[
55
+ {"xt": W / 2, "xb": 0, "hw": S / 2, "yt": H, "yb": 0},
56
+ {"xt": W / 2, "xb": W, "hw": S / 2, "yt": H, "yb": 0},
57
+ ]),
58
+ "D": Letter(W, rects=[
59
+ (0, 0, S, H), (0, H - S, 11, H), (0, 0, 11, S), (11 - S, S, 11, H - S),
60
+ ]),
61
+ "H": Letter(W, rects=[
62
+ (0, 0, S, H), (W - S, 0, W, H), (0, H / 2 - S / 2, W, H / 2 + S / 2),
63
+ ]),
64
+ "V": Letter(W, diags=[
65
+ {"xt": S / 2, "xb": W / 2, "hw": S / 2, "yt": H, "yb": 0},
66
+ {"xt": W - S / 2, "xb": W / 2, "hw": S / 2, "yt": H, "yb": 0},
67
+ ]),
68
+ "F": Letter(W, rects=[
69
+ (0, 0, S, H), (0, H - S, W, H), (0, H / 2 - S / 2, W * 0.75, H / 2 + S / 2),
70
+ ]),
71
+ "U": Letter(W, rects=[(0, S, S, H), (W - S, S, W, H), (0, 0, W, S)]),
72
+ "S": Letter(W, rects=[
73
+ (0, H - S, W, H), (0, H / 2 + S / 2, S, H - S),
74
+ (0, H / 2 - S / 2, W, H / 2 + S / 2),
75
+ (W - S, S, W, H / 2 - S / 2), (0, 0, W, S),
76
+ ]),
77
+ "I": Letter(8.0, rects=[(0, H - S, 8, H), (4 - S / 2, 0, 4 + S / 2, H), (0, 0, 8, S)]),
78
+ "O": Letter(W, rects=[(0, 0, S, H), (W - S, 0, W, H), (0, H - S, W, H), (0, 0, W, S)]),
79
+ "N": Letter(W, rects=[(0, 0, S, H), (W - S, 0, W, H)], diags=[
80
+ {"xt": S / 2, "xb": W - S / 2, "hw": S / 2, "yt": H, "yb": 0},
81
+ ]),
82
+ }
83
+
84
+
85
+ def layout_text(text: str, start_x: float = 0.0, gap: float = 3.0) -> tuple[list[Placed], float]:
86
+ """Lay out `text` left to right. Returns (placed shapes, total width)."""
87
+ placed = []
88
+ cx = start_x
89
+ for ch in text:
90
+ letter = FONT[ch]
91
+ placed.append(Placed(letter, cx))
92
+ cx += letter.width + gap
93
+ return placed, cx - gap - start_x
File without changes
gantrix/gui/app.py ADDED
@@ -0,0 +1,332 @@
1
+ """Minimal toolpath preview + job-control server.
2
+
3
+ Lets you preview a design as SVG, connect to a serial device, and run a
4
+ job against it — all from the browser, no CLI needed. Preview never
5
+ touches hardware; connect/run do, so those are explicit user actions.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import serial
10
+ from fastapi import FastAPI, HTTPException, Query
11
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
12
+ from pydantic import BaseModel
13
+
14
+ from gantrix.design.text import layout_text
15
+ from gantrix.gui import connection, job_runner
16
+ from gantrix.job import Job
17
+ from gantrix.planner.fill import FillSegment, plan_fill
18
+ from gantrix.tools.laser import LaserTool
19
+ from gantrix.transport import GrblError
20
+
21
+ app = FastAPI(title="gantrix preview")
22
+
23
+
24
+ class ConnectRequest(BaseModel):
25
+ port: str
26
+ baud: int = 115200
27
+
28
+
29
+ class RunTextRequest(BaseModel):
30
+ text: str
31
+ line_gap: float = 0.25
32
+ height: float = 20.0
33
+ power: int = 600
34
+ speed: int = 500
35
+
36
+
37
+ class RunQrRequest(BaseModel):
38
+ data: str
39
+ cell_size: float = 1.5
40
+ power: int = 600
41
+ speed: int = 500
42
+
43
+
44
+ @app.get("/api/ports")
45
+ def api_ports() -> list[dict]:
46
+ return connection.available_ports()
47
+
48
+
49
+ @app.get("/api/status")
50
+ def api_status() -> dict:
51
+ return connection.status()
52
+
53
+
54
+ @app.post("/api/connect")
55
+ def api_connect(req: ConnectRequest) -> JSONResponse:
56
+ try:
57
+ return JSONResponse(connection.connect(req.port, req.baud))
58
+ except serial.SerialException as e:
59
+ raise HTTPException(status_code=400, detail=str(e))
60
+
61
+
62
+ @app.post("/api/disconnect")
63
+ def api_disconnect() -> dict:
64
+ return connection.disconnect()
65
+
66
+
67
+ @app.post("/api/estop")
68
+ def api_estop() -> dict:
69
+ """Immediate hardware halt — GRBL's real-time soft-reset, sent directly
70
+ regardless of whether a job is stuck waiting on a stalled command. Also
71
+ flags any running job to stop retrying once it does unblock."""
72
+ job_runner.cancel()
73
+ try:
74
+ return connection.emergency_stop()
75
+ except RuntimeError as e:
76
+ raise HTTPException(status_code=400, detail=str(e))
77
+
78
+
79
+ @app.post("/api/home")
80
+ def api_home() -> dict:
81
+ if job_runner.status()["running"]:
82
+ raise HTTPException(status_code=409, detail="a job is running — cancel it first")
83
+ transport = connection.get_transport()
84
+ if transport is None:
85
+ raise HTTPException(status_code=400, detail="not connected to a device")
86
+ try:
87
+ transport.home()
88
+ except GrblError as e:
89
+ raise HTTPException(status_code=400, detail=str(e))
90
+ return connection.status()
91
+
92
+
93
+ @app.get("/api/job/status")
94
+ def api_job_status() -> dict:
95
+ return job_runner.status()
96
+
97
+
98
+ @app.post("/api/job/cancel")
99
+ def api_job_cancel() -> dict:
100
+ job_runner.cancel()
101
+ return job_runner.status()
102
+
103
+
104
+ @app.post("/api/run/text")
105
+ def api_run_text(req: RunTextRequest) -> dict:
106
+ placed, _ = layout_text(req.text.upper())
107
+ tool = LaserTool(power=req.power, speed=req.speed)
108
+
109
+ def build_job(transport, on_progress, should_cancel):
110
+ return Job(
111
+ transport, tool, placed, height=req.height, line_gap=req.line_gap,
112
+ on_progress=on_progress, should_cancel=should_cancel,
113
+ )
114
+
115
+ try:
116
+ job_runner.start(f"text {req.text!r}", build_job)
117
+ except RuntimeError as e:
118
+ raise HTTPException(status_code=400, detail=str(e))
119
+ return job_runner.status()
120
+
121
+
122
+ @app.post("/api/run/qr")
123
+ def api_run_qr(req: RunQrRequest) -> dict:
124
+ from gantrix.design.qrcode import layout_qr # optional dep: [design] extra
125
+
126
+ placed, _, h, line_gap = layout_qr(req.data, cell_size=req.cell_size)
127
+ tool = LaserTool(power=req.power, speed=req.speed)
128
+
129
+ def build_job(transport, on_progress, should_cancel):
130
+ return Job(
131
+ transport, tool, placed, height=h, line_gap=line_gap,
132
+ on_progress=on_progress, should_cancel=should_cancel,
133
+ )
134
+
135
+ try:
136
+ job_runner.start(f"qr {req.data!r}", build_job)
137
+ except RuntimeError as e:
138
+ raise HTTPException(status_code=400, detail=str(e))
139
+ return job_runner.status()
140
+
141
+
142
+ def _segments_to_svg(segments: list[FillSegment], width: float, height: float) -> str:
143
+ lines = "".join(
144
+ f'<line x1="{s.x_start:.2f}" y1="{height - s.y:.2f}" '
145
+ f'x2="{s.x_end:.2f}" y2="{height - s.y:.2f}" stroke="red" stroke-width="0.15" />'
146
+ for s in segments
147
+ )
148
+ return (
149
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
150
+ f'viewBox="-2 -2 {width + 4:.2f} {height + 4:.2f}" '
151
+ f'width="{(width + 4) * 8:.0f}" height="{(height + 4) * 8:.0f}">'
152
+ f'<rect x="-2" y="-2" width="{width + 4:.2f}" height="{height + 4:.2f}" fill="white" />'
153
+ f"{lines}</svg>"
154
+ )
155
+
156
+
157
+ @app.get("/", response_class=HTMLResponse)
158
+ def index() -> str:
159
+ return """
160
+ <html><body style="font-family: sans-serif; max-width: 700px; margin: 2rem auto;">
161
+ <h2>gantrix preview</h2>
162
+
163
+ <fieldset style="margin-bottom: 1.5rem;">
164
+ <legend>Serial connection</legend>
165
+ <select id="portSelect" style="min-width: 16rem;"></select>
166
+ <button type="button" onclick="refreshPorts()">Refresh</button>
167
+ <button type="button" onclick="doConnect()">Connect</button>
168
+ <button type="button" onclick="doDisconnect()">Disconnect</button>
169
+ <button type="button" onclick="doHome()">Home</button>
170
+ <button type="button" onclick="doEstop()"
171
+ style="background: #c00; color: white; font-weight: bold;">EMERGENCY STOP</button>
172
+ <div id="connStatus" style="margin-top: 0.5rem; font-family: monospace;">checking...</div>
173
+ </fieldset>
174
+
175
+ <fieldset style="margin-bottom: 1rem;">
176
+ <legend>Text</legend>
177
+ <input id="textInput" value="HAND" />
178
+ <input id="textLineGap" value="0.5" size="4" title="line gap (mm)" />
179
+ <input id="textPower" value="600" size="4" title="power 0-1000" />
180
+ <input id="textSpeed" value="500" size="4" title="speed mm/min" />
181
+ <button type="button" onclick="previewText()">Preview</button>
182
+ <button type="button" onclick="runText()">Print</button>
183
+ </fieldset>
184
+
185
+ <fieldset style="margin-bottom: 1.5rem;">
186
+ <legend>QR code</legend>
187
+ <input id="qrData" value="https://example.com" style="width: 16rem;" />
188
+ <input id="qrCellSize" value="1.5" size="4" title="mm per module" />
189
+ <input id="qrPower" value="600" size="4" title="power 0-1000" />
190
+ <input id="qrSpeed" value="500" size="4" title="speed mm/min" />
191
+ <button type="button" onclick="previewQr()">Preview</button>
192
+ <button type="button" onclick="runQr()">Print</button>
193
+ </fieldset>
194
+
195
+ <fieldset>
196
+ <legend>Job</legend>
197
+ <div id="jobStatus" style="font-family: monospace;">idle</div>
198
+ <button type="button" onclick="cancelJob()">Cancel</button>
199
+ </fieldset>
200
+
201
+ <script>
202
+ function previewText() {
203
+ const t = encodeURIComponent(document.getElementById('textInput').value);
204
+ const g = encodeURIComponent(document.getElementById('textLineGap').value);
205
+ window.open(`/preview?text=${t}&line_gap=${g}`, '_blank');
206
+ }
207
+ function previewQr() {
208
+ const d = encodeURIComponent(document.getElementById('qrData').value);
209
+ const c = encodeURIComponent(document.getElementById('qrCellSize').value);
210
+ window.open(`/preview/qr?data=${d}&cell_size=${c}`, '_blank');
211
+ }
212
+
213
+ async function refreshPorts() {
214
+ const ports = await (await fetch('/api/ports')).json();
215
+ const sel = document.getElementById('portSelect');
216
+ const prev = sel.value;
217
+ sel.innerHTML = '';
218
+ if (ports.length === 0) {
219
+ sel.innerHTML = '<option value="">(no ports found)</option>';
220
+ }
221
+ for (const p of ports) {
222
+ const opt = document.createElement('option');
223
+ opt.value = p.device;
224
+ opt.textContent = p.description ? `${p.device} — ${p.description}` : p.device;
225
+ sel.appendChild(opt);
226
+ }
227
+ if ([...sel.options].some(o => o.value === prev)) sel.value = prev;
228
+ }
229
+
230
+ async function refreshStatus() {
231
+ const s = await (await fetch('/api/status')).json();
232
+ const el = document.getElementById('connStatus');
233
+ el.textContent = s.connected ? `connected: ${s.port}` : 'disconnected';
234
+ }
235
+
236
+ async function doConnect() {
237
+ const port = document.getElementById('portSelect').value;
238
+ if (!port) { alert('No port selected'); return; }
239
+ const r = await fetch('/api/connect', {
240
+ method: 'POST',
241
+ headers: {'Content-Type': 'application/json'},
242
+ body: JSON.stringify({port}),
243
+ });
244
+ if (!r.ok) alert((await r.json()).detail || 'connect failed');
245
+ await refreshStatus();
246
+ }
247
+
248
+ async function doDisconnect() {
249
+ await fetch('/api/disconnect', {method: 'POST'});
250
+ await refreshStatus();
251
+ }
252
+
253
+ async function doHome() {
254
+ const r = await fetch('/api/home', {method: 'POST'});
255
+ if (!r.ok) alert((await r.json()).detail || 'home failed');
256
+ await refreshStatus();
257
+ }
258
+
259
+ async function doEstop() {
260
+ const r = await fetch('/api/estop', {method: 'POST'});
261
+ if (!r.ok) alert((await r.json()).detail || 'estop failed');
262
+ await refreshStatus();
263
+ await pollJob();
264
+ }
265
+
266
+ async function postJson(url, body) {
267
+ const r = await fetch(url, {
268
+ method: 'POST',
269
+ headers: {'Content-Type': 'application/json'},
270
+ body: JSON.stringify(body),
271
+ });
272
+ if (!r.ok) alert((await r.json()).detail || 'request failed');
273
+ return r;
274
+ }
275
+
276
+ function runText() {
277
+ postJson('/api/run/text', {
278
+ text: document.getElementById('textInput').value,
279
+ line_gap: parseFloat(document.getElementById('textLineGap').value),
280
+ power: parseInt(document.getElementById('textPower').value),
281
+ speed: parseInt(document.getElementById('textSpeed').value),
282
+ });
283
+ }
284
+
285
+ function runQr() {
286
+ postJson('/api/run/qr', {
287
+ data: document.getElementById('qrData').value,
288
+ cell_size: parseFloat(document.getElementById('qrCellSize').value),
289
+ power: parseInt(document.getElementById('qrPower').value),
290
+ speed: parseInt(document.getElementById('qrSpeed').value),
291
+ });
292
+ }
293
+
294
+ function cancelJob() {
295
+ fetch('/api/job/cancel', {method: 'POST'});
296
+ }
297
+
298
+ async function pollJob() {
299
+ const s = await (await fetch('/api/job/status')).json();
300
+ const el = document.getElementById('jobStatus');
301
+ if (s.running) {
302
+ el.textContent = `running ${s.label}: ${s.done}/${s.total} segments`;
303
+ } else if (s.error) {
304
+ el.textContent = `error: ${s.error}`;
305
+ } else {
306
+ el.textContent = 'idle';
307
+ }
308
+ }
309
+
310
+ refreshPorts();
311
+ refreshStatus();
312
+ setInterval(refreshStatus, 3000);
313
+ setInterval(pollJob, 1000);
314
+ </script>
315
+ </body></html>
316
+ """
317
+
318
+
319
+ @app.get("/preview")
320
+ def preview(text: str = Query("HAND"), line_gap: float = Query(0.5), height: float = Query(20.0)) -> Response:
321
+ placed, total_w = layout_text(text.upper())
322
+ segments = plan_fill(placed, height, line_gap)
323
+ return Response(content=_segments_to_svg(segments, total_w, height), media_type="image/svg+xml")
324
+
325
+
326
+ @app.get("/preview/qr")
327
+ def preview_qr(data: str = Query("https://example.com"), cell_size: float = Query(1.5)) -> Response:
328
+ from gantrix.design.qrcode import layout_qr # optional dep: [design] extra
329
+
330
+ placed, w, h, line_gap = layout_qr(data, cell_size=cell_size)
331
+ segments = plan_fill(placed, h, line_gap)
332
+ return Response(content=_segments_to_svg(segments, w, h), media_type="image/svg+xml")
@@ -0,0 +1,73 @@
1
+ """Holds the single GRBL serial connection the GUI process manages.
2
+
3
+ The preview server is otherwise stateless (each request just computes a
4
+ toolpath). A serial connection is different — it has to persist across
5
+ requests until the user disconnects, so it lives in one process-wide,
6
+ lock-guarded slot rather than being opened per-request.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+
12
+ from serial.tools import list_ports
13
+
14
+ from gantrix.transport import GrblTransport
15
+
16
+ _lock = threading.Lock()
17
+ _transport: GrblTransport | None = None
18
+
19
+
20
+ def available_ports() -> list[dict]:
21
+ return [{"device": p.device, "description": p.description} for p in list_ports.comports()]
22
+
23
+
24
+ def _status_locked() -> dict:
25
+ """Compute status; caller must already hold `_lock`."""
26
+ connected = _transport is not None and _transport.is_connected
27
+ return {"connected": connected, "port": _transport.port if connected else None}
28
+
29
+
30
+ def status() -> dict:
31
+ with _lock:
32
+ return _status_locked()
33
+
34
+
35
+ def connect(port: str, baud: int = 115200) -> dict:
36
+ global _transport
37
+ with _lock:
38
+ if _transport is not None and _transport.is_connected:
39
+ if _transport.port == port:
40
+ return _status_locked()
41
+ _transport.disconnect()
42
+ _transport = GrblTransport(port, baud)
43
+ _transport.connect() # raises serial.SerialException or GrblError on failure
44
+ return _status_locked()
45
+
46
+
47
+ def disconnect() -> dict:
48
+ global _transport
49
+ with _lock:
50
+ if _transport is not None:
51
+ _transport.disconnect()
52
+ return _status_locked()
53
+
54
+
55
+ def get_transport() -> GrblTransport | None:
56
+ """The live transport, if connected — for job-control endpoints."""
57
+ with _lock:
58
+ return _transport if (_transport is not None and _transport.is_connected) else None
59
+
60
+
61
+ def emergency_stop() -> dict:
62
+ """Write GRBL's real-time soft-reset byte directly, bypassing the normal
63
+ command queue. Deliberately does NOT touch `_transport`'s open/closed
64
+ state here — a job thread may be mid-read on the same handle, and
65
+ closing it out from under that read is its own hazard. The connection
66
+ is left open in a post-reset (alarm-locked) state; reconnect to run the
67
+ unlock handshake again before sending further commands.
68
+ """
69
+ with _lock:
70
+ if _transport is None or not _transport.is_connected:
71
+ raise RuntimeError("not connected to a device")
72
+ _transport.emergency_stop()
73
+ return _status_locked()
@@ -0,0 +1,60 @@
1
+ """Runs a Job in a background thread so an HTTP request doesn't block for
2
+ the full engrave duration, and exposes progress for the page to poll.
3
+
4
+ Only one job at a time — this is a single physical device, not a queue.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ from typing import Callable
10
+
11
+ from gantrix.gui import connection
12
+ from gantrix.job import Job
13
+ from gantrix.transport import GrblTransport
14
+
15
+ _lock = threading.Lock()
16
+ _cancel_event = threading.Event()
17
+ _thread: threading.Thread | None = None
18
+ _state = {"running": False, "done": 0, "total": 0, "label": None, "error": None}
19
+
20
+
21
+ def status() -> dict:
22
+ with _lock:
23
+ return dict(_state)
24
+
25
+
26
+ def start(label: str, build_job: Callable[[GrblTransport, Callable, Callable], Job]) -> None:
27
+ """`build_job(transport, on_progress, should_cancel)` must return a Job
28
+ wired up to run on the given transport."""
29
+ global _thread
30
+ with _lock:
31
+ if _state["running"]:
32
+ raise RuntimeError("a job is already running")
33
+ transport = connection.get_transport()
34
+ if transport is None:
35
+ raise RuntimeError("not connected to a device")
36
+ _cancel_event.clear()
37
+ _state.update(running=True, done=0, total=0, label=label, error=None)
38
+
39
+ def on_progress(done: int, total: int) -> None:
40
+ with _lock:
41
+ _state["done"] = done
42
+ _state["total"] = total
43
+
44
+ def _run() -> None:
45
+ try:
46
+ job = build_job(transport, on_progress, _cancel_event.is_set)
47
+ job.run()
48
+ except Exception as e:
49
+ with _lock:
50
+ _state["error"] = str(e)
51
+ finally:
52
+ with _lock:
53
+ _state["running"] = False
54
+
55
+ _thread = threading.Thread(target=_run, daemon=True)
56
+ _thread.start()
57
+
58
+
59
+ def cancel() -> None:
60
+ _cancel_event.set()
gantrix/job.py ADDED
@@ -0,0 +1,105 @@
1
+ """Orchestrates a design + tool + transport into a running gantry job."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ from gantrix.planner.fill import Placed, plan_fill
7
+ from gantrix.tools.base import Tool
8
+ from gantrix.transport import GrblTransport
9
+
10
+
11
+ class Job:
12
+ """A raster-fill job: sweep `placed` shapes and trace each fill segment
13
+ with `tool` engaged. Works for any tool implementing engage/disengage —
14
+ area-filling (laser) or drag-style (a held-down scoring pin). A future
15
+ tool that only acts at discrete points (e.g. a stamp) would need a
16
+ different job type driving the tool directly, since this one assumes
17
+ continuous engagement across each segment.
18
+
19
+ `port_or_transport` is either a serial port string — the Job opens and
20
+ closes its own connection, homing at the start (the CLI's usage) — or
21
+ an already-connected `GrblTransport` that the caller owns and keeps
22
+ open across jobs (the GUI's usage, once a device is connected).
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ port_or_transport: str | GrblTransport,
28
+ tool: Tool,
29
+ placed: list[Placed],
30
+ height: float,
31
+ line_gap: float = 0.25,
32
+ start_x: float = 5.0,
33
+ start_y: float = 5.0,
34
+ baud: int = 115200,
35
+ on_progress: Callable[[int, int], None] | None = None,
36
+ should_cancel: Callable[[], bool] | None = None,
37
+ ):
38
+ if isinstance(port_or_transport, str):
39
+ self.transport = GrblTransport(port_or_transport, baud)
40
+ self._owns_transport = True
41
+ else:
42
+ # Anything non-string is assumed to be an already-connected
43
+ # transport (duck-typed, not an isinstance check, so fakes in
44
+ # tests work the same as a real GrblTransport).
45
+ self.transport = port_or_transport
46
+ self._owns_transport = False
47
+ self.tool = tool
48
+ self.placed = placed
49
+ self.height = height
50
+ self.line_gap = line_gap
51
+ self.start_x = start_x
52
+ self.start_y = start_y
53
+ self.on_progress = on_progress
54
+ self.should_cancel = should_cancel
55
+
56
+ def run(self) -> None:
57
+ segments = plan_fill(self.placed, self.height, self.line_gap)
58
+ if not segments:
59
+ print("Nothing to engrave — design produced no fill segments.")
60
+ return
61
+
62
+ if self._owns_transport:
63
+ self.transport.connect()
64
+ try:
65
+ self._run_on(self.transport, segments)
66
+ finally:
67
+ if self._owns_transport:
68
+ self.transport.disconnect()
69
+
70
+ def _run_on(self, t: GrblTransport, segments) -> None:
71
+ print("Homing...")
72
+ t.home()
73
+ t.send("G90")
74
+ t.send("G21")
75
+ t.send(f"G0 X{self.start_x:.3f} Y{self.start_y:.3f} F{self.tool.travel_feed()}")
76
+
77
+ tool_on = False
78
+ cancelled = False
79
+ for i, seg in enumerate(segments):
80
+ if self.should_cancel is not None and self.should_cancel():
81
+ cancelled = True
82
+ break
83
+
84
+ xa = self.start_x + seg.x_start
85
+ xb = self.start_x + seg.x_end
86
+ ya = self.start_y + seg.y
87
+
88
+ if tool_on:
89
+ self.tool.disengage(t)
90
+ tool_on = False
91
+ t.send(f"G0 X{xa:.3f} Y{ya:.3f} F{self.tool.travel_feed()}")
92
+ self.tool.engage(t)
93
+ tool_on = True
94
+ t.send(f"G1 X{xb:.3f} Y{ya:.3f} F{self.tool.work_feed()}")
95
+
96
+ if self.on_progress is not None:
97
+ self.on_progress(i + 1, len(segments))
98
+ if (i + 1) % 60 == 0:
99
+ print(f" {i + 1}/{len(segments)} segments")
100
+
101
+ if tool_on:
102
+ self.tool.disengage(t)
103
+ if not cancelled:
104
+ t.send(f"G0 X0 Y0 F{self.tool.travel_feed()}")
105
+ print("Cancelled." if cancelled else "Done.")
File without changes
@@ -0,0 +1,86 @@
1
+ """Boustrophedon (zigzag raster) fill planning.
2
+
3
+ Generalized from a font-fill scanner: given anything that can report which
4
+ x-spans it occupies at a given y (a "Shape"), sweep scanlines across a
5
+ design and emit merged, direction-alternating fill segments that minimize
6
+ travel between them.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Iterable, Protocol
12
+
13
+
14
+ class Shape(Protocol):
15
+ """Anything that can report its horizontal extents at a given y."""
16
+
17
+ def spans_at_y(self, y: float) -> list[tuple[float, float]]:
18
+ """Return (x1, x2) fill spans at local y, x1 < x2, already merged."""
19
+ ...
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Placed:
24
+ """A shape positioned at an (x, y) offset within a larger design."""
25
+
26
+ shape: Shape
27
+ x: float
28
+ y: float = 0.0
29
+
30
+
31
+ def _merge(spans: list[tuple[float, float]], tol: float = 0.01) -> list[list[float]]:
32
+ if not spans:
33
+ return []
34
+ spans = sorted(spans)
35
+ merged = [list(spans[0])]
36
+ for x1, x2 in spans[1:]:
37
+ if x1 <= merged[-1][1] + tol:
38
+ merged[-1][1] = max(merged[-1][1], x2)
39
+ else:
40
+ merged.append([x1, x2])
41
+ return merged
42
+
43
+
44
+ def scanline_ys(height: float, line_gap: float) -> list[float]:
45
+ ys, y = [], 0.0
46
+ while y <= height + 0.001:
47
+ ys.append(round(y, 4))
48
+ y += line_gap
49
+ return ys
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class FillSegment:
54
+ """One engrave pass: move from (x_start, y) to (x_end, y) with tool on."""
55
+
56
+ x_start: float
57
+ x_end: float
58
+ y: float
59
+
60
+
61
+ def plan_fill(placed: Iterable[Placed], height: float, line_gap: float) -> list[FillSegment]:
62
+ """Sweep the design bottom-to-top, alternating direction each active line."""
63
+ placed = list(placed)
64
+ segments: list[FillSegment] = []
65
+ direction = 1
66
+
67
+ for yv in scanline_ys(height, line_gap):
68
+ raw = []
69
+ for p in placed:
70
+ local_y = yv - p.y
71
+ for x1, x2 in p.shape.spans_at_y(local_y):
72
+ raw.append((p.x + x1, p.x + x2))
73
+ if not raw:
74
+ continue
75
+
76
+ merged = _merge(raw)
77
+ if direction == -1:
78
+ merged.reverse()
79
+
80
+ for x1, x2 in merged:
81
+ xa, xb = (x1, x2) if direction == 1 else (x2, x1)
82
+ segments.append(FillSegment(xa, xb, yv))
83
+
84
+ direction *= -1
85
+
86
+ return segments
@@ -0,0 +1,4 @@
1
+ from gantrix.tools.base import Tool
2
+ from gantrix.tools.laser import LaserTool
3
+
4
+ __all__ = ["Tool", "LaserTool"]
gantrix/tools/base.py ADDED
@@ -0,0 +1,32 @@
1
+ """Tool plugin interface: anything mounted on the gantry head."""
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+
6
+ from gantrix.transport import GrblTransport
7
+
8
+
9
+ class Tool(ABC):
10
+ """A device mounted on the gantry that acts at points along a path.
11
+
12
+ `fills_area` tells the planner whether to raster-fill enclosed regions
13
+ (laser, spray head) or just trace outlines (solenoid stamp, pen, embosser).
14
+ """
15
+
16
+ fills_area: bool = True
17
+
18
+ @abstractmethod
19
+ def engage(self, transport: GrblTransport) -> None:
20
+ """Activate the tool (laser on, solenoid extend, pen down, ...)."""
21
+
22
+ @abstractmethod
23
+ def disengage(self, transport: GrblTransport) -> None:
24
+ """Deactivate the tool."""
25
+
26
+ def travel_feed(self) -> int:
27
+ """Feed rate (mm/min) for moves with the tool off."""
28
+ return 4000
29
+
30
+ def work_feed(self) -> int:
31
+ """Feed rate (mm/min) for moves with the tool engaged."""
32
+ return 500
gantrix/tools/laser.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from gantrix.transport import GrblTransport
4
+ from gantrix.tools.base import Tool
5
+
6
+
7
+ class LaserTool(Tool):
8
+ """Continuous-power laser (M3/M5), fills enclosed regions."""
9
+
10
+ fills_area = True
11
+
12
+ def __init__(self, power: int = 600, speed: int = 500, travel: int = 4000):
13
+ assert 0 <= power <= 1000, "power is 0-1000 per GRBL's $30 max spindle speed"
14
+ self.power = power
15
+ self.speed = speed
16
+ self.travel = travel
17
+
18
+ def engage(self, transport: GrblTransport) -> None:
19
+ transport.send(f"M3 S{self.power}")
20
+
21
+ def disengage(self, transport: GrblTransport) -> None:
22
+ transport.send("M5")
23
+
24
+ def travel_feed(self) -> int:
25
+ return self.travel
26
+
27
+ def work_feed(self) -> int:
28
+ return self.speed
gantrix/transport.py ADDED
@@ -0,0 +1,108 @@
1
+ """Serial transport to a GRBL-speaking controller."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ import serial
7
+
8
+
9
+ class GrblError(RuntimeError):
10
+ pass
11
+
12
+
13
+ class GrblTransport:
14
+ """Owns the serial connection and command/response handshake with GRBL."""
15
+
16
+ def __init__(self, port: str, baud: int = 115200, boot_delay: float = 4.0):
17
+ self.port = port
18
+ self.baud = baud
19
+ self.boot_delay = boot_delay
20
+ self._ser: serial.Serial | None = None
21
+
22
+ @property
23
+ def is_connected(self) -> bool:
24
+ return self._ser is not None and self._ser.is_open
25
+
26
+ def connect(self) -> None:
27
+ if self.is_connected:
28
+ return
29
+ self._ser = serial.Serial(self.port, self.baud, timeout=2)
30
+ try:
31
+ time.sleep(self.boot_delay) # let the controller finish booting after DTR reset
32
+ self._ser.flushInput()
33
+ self._ser.write(b"\r\n\r\n")
34
+ time.sleep(0.5)
35
+ self._ser.flushInput()
36
+
37
+ # Safety: force the tool off before touching alarm state or homing.
38
+ # If a previous run crashed mid-job, GRBL holds whatever tool state
39
+ # it last had — the host process dying does NOT turn the tool off.
40
+ self.send("M5", timeout=5)
41
+
42
+ self.send("$X", timeout=5) # clear alarm lock
43
+ except Exception:
44
+ # Don't leave a half-initialized handle open — a retry needs a
45
+ # clean slate, not a leaked fd that looks "connected" but isn't.
46
+ self._ser.close()
47
+ self._ser = None
48
+ raise
49
+
50
+ def disconnect(self) -> None:
51
+ if self._ser is not None:
52
+ try:
53
+ self.send("M5", timeout=5) # tool off no matter how we're exiting
54
+ except GrblError:
55
+ pass # the link may already be dead — still close it below
56
+ finally:
57
+ self._ser.close()
58
+ self._ser = None
59
+
60
+ def emergency_stop(self) -> None:
61
+ """Immediate, best-effort hardware halt: GRBL's real-time soft-reset
62
+ byte, sent directly — bypasses the normal line-by-line command queue
63
+ entirely, so it works even while another thread is blocked waiting
64
+ on a response to a stuck command. Turns the tool off as a side
65
+ effect of resetting GRBL's state, but leaves the connection open in
66
+ a post-reset (alarm-locked) state — reconnect to re-run the unlock
67
+ handshake before sending further commands.
68
+ """
69
+ assert self._ser is not None, "transport used outside a `with` block"
70
+ self._ser.write(b"\x18") # Ctrl-X, GRBL real-time soft reset
71
+
72
+ def __enter__(self) -> "GrblTransport":
73
+ self.connect()
74
+ return self
75
+
76
+ def __exit__(self, exc_type, exc, tb):
77
+ self.disconnect()
78
+
79
+ def home(self, timeout: float = 60.0) -> None:
80
+ self.send("$H", timeout=timeout)
81
+
82
+ def send(self, cmd: str, timeout: float = 30.0) -> bool:
83
+ assert self._ser is not None, "transport used outside a `with` block"
84
+ self._ser.write((cmd.strip() + "\n").encode())
85
+ ok, reason = self._wait_ok(timeout)
86
+ if not ok:
87
+ raise GrblError(f"no response to {cmd!r} ({reason})")
88
+ return True
89
+
90
+ def _wait_ok(self, timeout: float) -> tuple[bool, str]:
91
+ deadline = time.time() + timeout
92
+ while time.time() < deadline:
93
+ line = self._ser.readline().decode(errors="replace").strip()
94
+ if line == "ok":
95
+ return True, "ok"
96
+ if line.startswith("error"):
97
+ print(f" GRBL error: {line}")
98
+ return False, line
99
+ if line.startswith("Grbl") or "['$' for help]" in line:
100
+ # controller rebooted (soft reset, brownout, ...) mid-command —
101
+ # no point waiting out the full timeout for a reply that will
102
+ # never come from the command we sent before the reboot
103
+ print(f" GRBL: controller reset mid-command ({line})")
104
+ return False, "controller reset"
105
+ if line and not line.startswith("<"):
106
+ print(f" GRBL: {line}")
107
+ print(f" GRBL: timed out waiting for 'ok' after {timeout}s")
108
+ return False, f"timed out after {timeout}s"
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: gantrix
3
+ Version: 0.1.0
4
+ Summary: A framework for controlling gantry-based (GRBL) devices with swappable tool heads — laser, solenoid, pen, and more.
5
+ Author: devk-op
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: pyserial>=3.5
11
+ Provides-Extra: gui
12
+ Requires-Dist: fastapi>=0.110; extra == "gui"
13
+ Requires-Dist: uvicorn>=0.29; extra == "gui"
14
+ Provides-Extra: design
15
+ Requires-Dist: Pillow>=10.0; extra == "design"
16
+ Requires-Dist: qrcode>=7.4; extra == "design"
17
+ Requires-Dist: svgpathtools>=1.6; extra == "design"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+ Requires-Dist: httpx>=0.27; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # gantrix
24
+
25
+ A small framework for driving GRBL-based gantry devices with swappable tool
26
+ heads — laser engraver today, whatever else you bolt to the carriage later
27
+ (solenoid stamp, pen plotter, spray head, ...). Grew out of a one-off laser
28
+ text-engraving script; generalized so the same motion/fill planning works
29
+ for any tool, without being locked to the laser.
30
+
31
+ ## Layout
32
+
33
+ - `gantrix.transport` — serial connection to GRBL, command/ack handshake,
34
+ homes with the tool forced off first (a previous crashed run can leave a
35
+ tool physically engaged even after the host process dies). A command
36
+ that gets no reply at all (dead link, controller reset) raises `GrblError`
37
+ rather than being silently swallowed; a mid-command controller reboot is
38
+ detected from its boot banner instead of waiting out the full timeout.
39
+ `emergency_stop()` sends GRBL's real-time soft-reset byte directly,
40
+ bypassing the normal command queue, so it works even while another
41
+ thread is blocked waiting on a stuck command.
42
+ - `gantrix.tools` — the `Tool` plugin interface (`engage`/`disengage` +
43
+ feed rates). `LaserTool` (M3/M5 with power) is the only concrete
44
+ implementation shipped so far; new device types are added by subclassing
45
+ `Tool` (see `src/gantrix/tools/laser.py` as the reference).
46
+ - `gantrix.design` — turns a design into `Shape`s the planner can sweep.
47
+ `design.text` is a small stroke font (rects + tapered diagonals).
48
+ `design.shapes.RasterShape` is a generic bitmap-grid `Shape`, backing both
49
+ `design.qrcode` (any QR-encodable data) and `design.image` (any image —
50
+ logo, silhouette, photo — thresholded to black/white at a configurable
51
+ resolution). True vector/SVG import (arbitrary curves, not just bitmaps)
52
+ is the natural next addition here.
53
+ - `gantrix.planner.fill` — generic boustrophedon (zigzag raster) fill:
54
+ given anything exposing `spans_at_y(y)`, sweeps scanlines and emits
55
+ direction-alternating segments that minimize travel.
56
+ - `gantrix.job` — wires a design + tool + transport into a runnable job.
57
+ Accepts either a port string (opens/closes its own connection — CLI
58
+ usage) or an already-connected transport (keeps it open — GUI usage).
59
+ - `gantrix.gui.app` — preview a design as SVG, connect/disconnect a serial
60
+ port, and run a job — all from the browser. `gantrix.gui.connection`
61
+ holds the one live serial connection the process manages;
62
+ `gantrix.gui.job_runner` runs a `Job` in a background thread (so the
63
+ HTTP request returns immediately) and exposes progress + cancellation
64
+ for the page to poll. Only one job at a time — it's a single physical
65
+ device, not a queue. Two ways to stop: **Cancel** is cooperative (checked
66
+ between fill segments — fine for "I want a different design," not fast
67
+ enough to rely on if something's actually wrong); **EMERGENCY STOP** hits
68
+ the hardware directly via `emergency_stop()` and works regardless of
69
+ whether a command is stuck. **Home** manually re-homes ($H) when
70
+ connected and no job is running.
71
+
72
+ ## Quickstart
73
+
74
+ ```bash
75
+ pip install -e ".[dev,gui,design]"
76
+ pytest # no hardware needed
77
+
78
+ # preview a design in the browser (no hardware needed)
79
+ uvicorn gantrix.gui.app:app --reload
80
+ # open http://127.0.0.1:8000
81
+
82
+ # run it for real
83
+ gantrix text HAND --port /dev/cu.usbserial-XXXXXX
84
+ gantrix qr "https://example.com" --port /dev/cu.usbserial-XXXXXX --cell-size 1.5
85
+ gantrix image logo.png --port /dev/cu.usbserial-XXXXXX --height 40 --cell-size 0.3
86
+ ```
87
+
88
+ ## Adding a new device
89
+
90
+ Swapping tools without touching the design or planner is the point of the
91
+ `Tool` split. To support a new actuator, subclass `Tool` and implement
92
+ `engage`/`disengage` (see `src/gantrix/tools/laser.py`):
93
+
94
+ ```python
95
+ class MyTool(Tool):
96
+ def engage(self, transport):
97
+ transport.send("M62 P1") # e.g. a GRBL digital output pin
98
+
99
+ def disengage(self, transport):
100
+ transport.send("M63 P1")
101
+ ```
102
+
103
+ Then use it exactly like `LaserTool` — same `layout_text`/`layout_qr`
104
+ output, same `Job`, just a different tool instance passed in.
105
+
106
+ ## Status
107
+
108
+ Early scaffold. Laser path is validated against a working engrave script.
109
+ QR fill segments have been round-trip verified (reconstructing the module
110
+ grid from planned segments reproduces the source matrix exactly) and
111
+ rendered to confirm the code is well-formed. Only one concrete `Tool`
112
+ (laser) ships so far — the interface is there for more, none built yet.
113
+ Vector/SVG import and shape-drawing in the GUI (not just text/QR forms) are
114
+ not built yet either. The GUI can connect/disconnect, run a job (text or
115
+ QR), Cancel, Home, and hardware-level EMERGENCY STOP. A real incident
116
+ during testing (the controller stopped responding mid-job) surfaced that
117
+ failures weren't being surfaced at all — `send()` used to return `False`
118
+ on a dead link and nothing checked it, so the job just kept retrying into
119
+ the void with no visible error. Fixed: failures now raise and show up in
120
+ the job's `error` field instead of hanging silently.
@@ -0,0 +1,24 @@
1
+ gantrix/__init__.py,sha256=Bjwv6Ju8fZCItIYflNAnLAZLUT4pBkt1GDA0JH_A7UY,130
2
+ gantrix/cli.py,sha256=6_7ifkTiIyStt9Wn-Iu4ax5A1ac1dsnxRocUSZ9KWqQ,2972
3
+ gantrix/job.py,sha256=GTtfz7b86SVLy9nEFza_GlT-itm41aC2tTI7s3406d0,3855
4
+ gantrix/transport.py,sha256=xRAjLT3ffw_WF_QUyUj4i7C5qIAHTAhi5HDMXaC4UMk,4275
5
+ gantrix/design/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ gantrix/design/image.py,sha256=FnS10PgSwT_zMRgYT3kOF3_KWONKCXI9V3IVKwrr7x4,1735
7
+ gantrix/design/qrcode.py,sha256=Sch_f_PhhZo6HYPOd4E7jvxFTyGKQS-ZMvV5uD6UibQ,1240
8
+ gantrix/design/shapes.py,sha256=Tnrw6ZyIMBwgSs28qSEbgar5IeZT-10JZf70wEkZAf4,1582
9
+ gantrix/design/text.py,sha256=AntqQhwGBNI2wV2TDplguDDuEzS_IVGVnhqktl408tY,3580
10
+ gantrix/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ gantrix/gui/app.py,sha256=Ekrq8svXSemNR1VFbJthu4d5w1l1VwRw9-0GifDyJzQ,11950
12
+ gantrix/gui/connection.py,sha256=_-tDQXQbeBC_jqevyDygYCov8gpz1UP-UKjVLdE5jlE,2507
13
+ gantrix/gui/job_runner.py,sha256=S7QOwwUcdeLR91KqoFCIiips34sgGqinePAn7zbWWvU,1831
14
+ gantrix/planner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ gantrix/planner/fill.py,sha256=nwtresA3rFuU-KLxcL7UPhXsqfEI0iC2b8QQAqPmBew,2344
16
+ gantrix/tools/__init__.py,sha256=Y0PmMZ4mQV2cYwfyHmRVwjezrzATgt-58wxTrGDXWbg,111
17
+ gantrix/tools/base.py,sha256=J5s9W_vrogxAhwaxlHheScjXd8yYyh3vLyY5s3C89_M,970
18
+ gantrix/tools/laser.py,sha256=t6ohu7ll6EWq6lWT2czvhQtvrxSCdZH_ZtaDHAigUTM,791
19
+ gantrix-0.1.0.dist-info/licenses/LICENSE,sha256=ggbjvAzg-9eXLZvwwupSZcbEWHvnHk0o_Qw-w0pphOc,1064
20
+ gantrix-0.1.0.dist-info/METADATA,sha256=58QO_YC1ro1mAy6lW5L0VIpXEP47afPqrdmDus1PSLc,5732
21
+ gantrix-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
22
+ gantrix-0.1.0.dist-info/entry_points.txt,sha256=2azwDnwE1rBvcNYfvYVDzZBuv38qlyUJTqDRKyXp-0I,45
23
+ gantrix-0.1.0.dist-info/top_level.txt,sha256=VabMlu9x_W-tQ_VwWsEYthFDbvBgr3ANSNDtAcn9xxM,8
24
+ gantrix-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gantrix = gantrix.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devk-op
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 @@
1
+ gantrix