gantrix 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- gantrix-0.1.0/PKG-INFO +120 -0
- gantrix-0.1.0/README.md +98 -0
- gantrix-0.1.0/pyproject.toml +26 -0
- gantrix-0.1.0/setup.cfg +4 -0
- gantrix-0.1.0/src/gantrix/__init__.py +5 -0
- gantrix-0.1.0/src/gantrix/cli.py +68 -0
- gantrix-0.1.0/src/gantrix/design/__init__.py +0 -0
- gantrix-0.1.0/src/gantrix/design/image.py +51 -0
- gantrix-0.1.0/src/gantrix/design/qrcode.py +29 -0
- gantrix-0.1.0/src/gantrix/design/shapes.py +39 -0
- gantrix-0.1.0/src/gantrix/design/text.py +93 -0
- gantrix-0.1.0/src/gantrix/gui/__init__.py +0 -0
- gantrix-0.1.0/src/gantrix/gui/app.py +332 -0
- gantrix-0.1.0/src/gantrix/gui/connection.py +73 -0
- gantrix-0.1.0/src/gantrix/gui/job_runner.py +60 -0
- gantrix-0.1.0/src/gantrix/job.py +105 -0
- gantrix-0.1.0/src/gantrix/planner/__init__.py +0 -0
- gantrix-0.1.0/src/gantrix/planner/fill.py +86 -0
- gantrix-0.1.0/src/gantrix/tools/__init__.py +4 -0
- gantrix-0.1.0/src/gantrix/tools/base.py +32 -0
- gantrix-0.1.0/src/gantrix/tools/laser.py +28 -0
- gantrix-0.1.0/src/gantrix/transport.py +108 -0
- gantrix-0.1.0/src/gantrix.egg-info/PKG-INFO +120 -0
- gantrix-0.1.0/src/gantrix.egg-info/SOURCES.txt +32 -0
- gantrix-0.1.0/src/gantrix.egg-info/dependency_links.txt +1 -0
- gantrix-0.1.0/src/gantrix.egg-info/entry_points.txt +2 -0
- gantrix-0.1.0/src/gantrix.egg-info/requires.txt +14 -0
- gantrix-0.1.0/src/gantrix.egg-info/top_level.txt +1 -0
- gantrix-0.1.0/tests/test_connection.py +94 -0
- gantrix-0.1.0/tests/test_design_shapes.py +57 -0
- gantrix-0.1.0/tests/test_fill.py +51 -0
- gantrix-0.1.0/tests/test_job_runner.py +118 -0
- gantrix-0.1.0/tests/test_transport.py +135 -0
gantrix-0.1.0/LICENSE
ADDED
|
@@ -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.
|
gantrix-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
gantrix-0.1.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# gantrix
|
|
2
|
+
|
|
3
|
+
A small framework for driving GRBL-based gantry devices with swappable tool
|
|
4
|
+
heads — laser engraver today, whatever else you bolt to the carriage later
|
|
5
|
+
(solenoid stamp, pen plotter, spray head, ...). Grew out of a one-off laser
|
|
6
|
+
text-engraving script; generalized so the same motion/fill planning works
|
|
7
|
+
for any tool, without being locked to the laser.
|
|
8
|
+
|
|
9
|
+
## Layout
|
|
10
|
+
|
|
11
|
+
- `gantrix.transport` — serial connection to GRBL, command/ack handshake,
|
|
12
|
+
homes with the tool forced off first (a previous crashed run can leave a
|
|
13
|
+
tool physically engaged even after the host process dies). A command
|
|
14
|
+
that gets no reply at all (dead link, controller reset) raises `GrblError`
|
|
15
|
+
rather than being silently swallowed; a mid-command controller reboot is
|
|
16
|
+
detected from its boot banner instead of waiting out the full timeout.
|
|
17
|
+
`emergency_stop()` sends GRBL's real-time soft-reset byte directly,
|
|
18
|
+
bypassing the normal command queue, so it works even while another
|
|
19
|
+
thread is blocked waiting on a stuck command.
|
|
20
|
+
- `gantrix.tools` — the `Tool` plugin interface (`engage`/`disengage` +
|
|
21
|
+
feed rates). `LaserTool` (M3/M5 with power) is the only concrete
|
|
22
|
+
implementation shipped so far; new device types are added by subclassing
|
|
23
|
+
`Tool` (see `src/gantrix/tools/laser.py` as the reference).
|
|
24
|
+
- `gantrix.design` — turns a design into `Shape`s the planner can sweep.
|
|
25
|
+
`design.text` is a small stroke font (rects + tapered diagonals).
|
|
26
|
+
`design.shapes.RasterShape` is a generic bitmap-grid `Shape`, backing both
|
|
27
|
+
`design.qrcode` (any QR-encodable data) and `design.image` (any image —
|
|
28
|
+
logo, silhouette, photo — thresholded to black/white at a configurable
|
|
29
|
+
resolution). True vector/SVG import (arbitrary curves, not just bitmaps)
|
|
30
|
+
is the natural next addition here.
|
|
31
|
+
- `gantrix.planner.fill` — generic boustrophedon (zigzag raster) fill:
|
|
32
|
+
given anything exposing `spans_at_y(y)`, sweeps scanlines and emits
|
|
33
|
+
direction-alternating segments that minimize travel.
|
|
34
|
+
- `gantrix.job` — wires a design + tool + transport into a runnable job.
|
|
35
|
+
Accepts either a port string (opens/closes its own connection — CLI
|
|
36
|
+
usage) or an already-connected transport (keeps it open — GUI usage).
|
|
37
|
+
- `gantrix.gui.app` — preview a design as SVG, connect/disconnect a serial
|
|
38
|
+
port, and run a job — all from the browser. `gantrix.gui.connection`
|
|
39
|
+
holds the one live serial connection the process manages;
|
|
40
|
+
`gantrix.gui.job_runner` runs a `Job` in a background thread (so the
|
|
41
|
+
HTTP request returns immediately) and exposes progress + cancellation
|
|
42
|
+
for the page to poll. Only one job at a time — it's a single physical
|
|
43
|
+
device, not a queue. Two ways to stop: **Cancel** is cooperative (checked
|
|
44
|
+
between fill segments — fine for "I want a different design," not fast
|
|
45
|
+
enough to rely on if something's actually wrong); **EMERGENCY STOP** hits
|
|
46
|
+
the hardware directly via `emergency_stop()` and works regardless of
|
|
47
|
+
whether a command is stuck. **Home** manually re-homes ($H) when
|
|
48
|
+
connected and no job is running.
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install -e ".[dev,gui,design]"
|
|
54
|
+
pytest # no hardware needed
|
|
55
|
+
|
|
56
|
+
# preview a design in the browser (no hardware needed)
|
|
57
|
+
uvicorn gantrix.gui.app:app --reload
|
|
58
|
+
# open http://127.0.0.1:8000
|
|
59
|
+
|
|
60
|
+
# run it for real
|
|
61
|
+
gantrix text HAND --port /dev/cu.usbserial-XXXXXX
|
|
62
|
+
gantrix qr "https://example.com" --port /dev/cu.usbserial-XXXXXX --cell-size 1.5
|
|
63
|
+
gantrix image logo.png --port /dev/cu.usbserial-XXXXXX --height 40 --cell-size 0.3
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Adding a new device
|
|
67
|
+
|
|
68
|
+
Swapping tools without touching the design or planner is the point of the
|
|
69
|
+
`Tool` split. To support a new actuator, subclass `Tool` and implement
|
|
70
|
+
`engage`/`disengage` (see `src/gantrix/tools/laser.py`):
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
class MyTool(Tool):
|
|
74
|
+
def engage(self, transport):
|
|
75
|
+
transport.send("M62 P1") # e.g. a GRBL digital output pin
|
|
76
|
+
|
|
77
|
+
def disengage(self, transport):
|
|
78
|
+
transport.send("M63 P1")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then use it exactly like `LaserTool` — same `layout_text`/`layout_qr`
|
|
82
|
+
output, same `Job`, just a different tool instance passed in.
|
|
83
|
+
|
|
84
|
+
## Status
|
|
85
|
+
|
|
86
|
+
Early scaffold. Laser path is validated against a working engrave script.
|
|
87
|
+
QR fill segments have been round-trip verified (reconstructing the module
|
|
88
|
+
grid from planned segments reproduces the source matrix exactly) and
|
|
89
|
+
rendered to confirm the code is well-formed. Only one concrete `Tool`
|
|
90
|
+
(laser) ships so far — the interface is there for more, none built yet.
|
|
91
|
+
Vector/SVG import and shape-drawing in the GUI (not just text/QR forms) are
|
|
92
|
+
not built yet either. The GUI can connect/disconnect, run a job (text or
|
|
93
|
+
QR), Cancel, Home, and hardware-level EMERGENCY STOP. A real incident
|
|
94
|
+
during testing (the controller stopped responding mid-job) surfaced that
|
|
95
|
+
failures weren't being surfaced at all — `send()` used to return `False`
|
|
96
|
+
on a dead link and nothing checked it, so the job just kept retrying into
|
|
97
|
+
the void with no visible error. Fixed: failures now raise and show up in
|
|
98
|
+
the job's `error` field instead of hanging silently.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gantrix"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A framework for controlling gantry-based (GRBL) devices with swappable tool heads — laser, solenoid, pen, and more."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "devk-op" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"pyserial>=3.5",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
gui = ["fastapi>=0.110", "uvicorn>=0.29"]
|
|
19
|
+
design = ["Pillow>=10.0", "qrcode>=7.4", "svgpathtools>=1.6"]
|
|
20
|
+
dev = ["pytest>=8.0", "httpx>=0.27"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
gantrix = "gantrix.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
gantrix-0.1.0/setup.cfg
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
|
|
@@ -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
|