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 +5 -0
- gantrix/cli.py +68 -0
- gantrix/design/__init__.py +0 -0
- gantrix/design/image.py +51 -0
- gantrix/design/qrcode.py +29 -0
- gantrix/design/shapes.py +39 -0
- gantrix/design/text.py +93 -0
- gantrix/gui/__init__.py +0 -0
- gantrix/gui/app.py +332 -0
- gantrix/gui/connection.py +73 -0
- gantrix/gui/job_runner.py +60 -0
- gantrix/job.py +105 -0
- gantrix/planner/__init__.py +0 -0
- gantrix/planner/fill.py +86 -0
- gantrix/tools/__init__.py +4 -0
- gantrix/tools/base.py +32 -0
- gantrix/tools/laser.py +28 -0
- gantrix/transport.py +108 -0
- gantrix-0.1.0.dist-info/METADATA +120 -0
- gantrix-0.1.0.dist-info/RECORD +24 -0
- gantrix-0.1.0.dist-info/WHEEL +5 -0
- gantrix-0.1.0.dist-info/entry_points.txt +2 -0
- gantrix-0.1.0.dist-info/licenses/LICENSE +21 -0
- gantrix-0.1.0.dist-info/top_level.txt +1 -0
gantrix/__init__.py
ADDED
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
|
gantrix/design/image.py
ADDED
|
@@ -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
|
gantrix/design/qrcode.py
ADDED
|
@@ -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
|
gantrix/design/shapes.py
ADDED
|
@@ -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
|
gantrix/gui/__init__.py
ADDED
|
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
|
gantrix/planner/fill.py
ADDED
|
@@ -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
|
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,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
|