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.
Files changed (34) hide show
  1. gantrix-0.1.0/LICENSE +21 -0
  2. gantrix-0.1.0/PKG-INFO +120 -0
  3. gantrix-0.1.0/README.md +98 -0
  4. gantrix-0.1.0/pyproject.toml +26 -0
  5. gantrix-0.1.0/setup.cfg +4 -0
  6. gantrix-0.1.0/src/gantrix/__init__.py +5 -0
  7. gantrix-0.1.0/src/gantrix/cli.py +68 -0
  8. gantrix-0.1.0/src/gantrix/design/__init__.py +0 -0
  9. gantrix-0.1.0/src/gantrix/design/image.py +51 -0
  10. gantrix-0.1.0/src/gantrix/design/qrcode.py +29 -0
  11. gantrix-0.1.0/src/gantrix/design/shapes.py +39 -0
  12. gantrix-0.1.0/src/gantrix/design/text.py +93 -0
  13. gantrix-0.1.0/src/gantrix/gui/__init__.py +0 -0
  14. gantrix-0.1.0/src/gantrix/gui/app.py +332 -0
  15. gantrix-0.1.0/src/gantrix/gui/connection.py +73 -0
  16. gantrix-0.1.0/src/gantrix/gui/job_runner.py +60 -0
  17. gantrix-0.1.0/src/gantrix/job.py +105 -0
  18. gantrix-0.1.0/src/gantrix/planner/__init__.py +0 -0
  19. gantrix-0.1.0/src/gantrix/planner/fill.py +86 -0
  20. gantrix-0.1.0/src/gantrix/tools/__init__.py +4 -0
  21. gantrix-0.1.0/src/gantrix/tools/base.py +32 -0
  22. gantrix-0.1.0/src/gantrix/tools/laser.py +28 -0
  23. gantrix-0.1.0/src/gantrix/transport.py +108 -0
  24. gantrix-0.1.0/src/gantrix.egg-info/PKG-INFO +120 -0
  25. gantrix-0.1.0/src/gantrix.egg-info/SOURCES.txt +32 -0
  26. gantrix-0.1.0/src/gantrix.egg-info/dependency_links.txt +1 -0
  27. gantrix-0.1.0/src/gantrix.egg-info/entry_points.txt +2 -0
  28. gantrix-0.1.0/src/gantrix.egg-info/requires.txt +14 -0
  29. gantrix-0.1.0/src/gantrix.egg-info/top_level.txt +1 -0
  30. gantrix-0.1.0/tests/test_connection.py +94 -0
  31. gantrix-0.1.0/tests/test_design_shapes.py +57 -0
  32. gantrix-0.1.0/tests/test_fill.py +51 -0
  33. gantrix-0.1.0/tests/test_job_runner.py +118 -0
  34. 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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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