Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ build/
dist/
*.egg-info/
video/
manuals
*.bin
148 changes: 144 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ Getting started

This library is available from PyPi as `rollingmill`.

$ pip install rollingmill
$ pip install rollingmill # core: TUI + RML/G-code stream
$ pip install "rollingmill[maze]" # adds maze generator (needs cairo headers on Linux)
$ pip install "rollingmill[relief]" # adds image-carving deps (numpy/pillow/scipy)
$ pip install "rollingmill[vcarve]" # adds SVG V-carving deps (numpy/scipy/scikit-image/svgelements)
$ rollingmill # launch TUI on real hardware
$ rollingmill --file sample-nc/example-doc-part.gcode # stream an NC file

Expand Down Expand Up @@ -172,13 +174,27 @@ When this unit is installed, the X-, Y-, and Z-axis travel of the MDX-40A is red
These limits are enforced in firmware, including from the physical jog controls on the machine itself. The X-restriction clears the rotary axis body, but not the rotary vice or tailstock, so care is needed.


Tool library
-----

The path emitters (`relief3D`, `totem`, `maze`, `spiral`, `text`, `cuthole`, `pocket`) share a host-side cutting-tool library at `~/.rollingmill/tools.json`. Each entry carries geometry (type, diameter, length, V-bit half-angle, etc.) plus suggested feeds and spindle speed, and may reference one of the 8 firmware diameter-offset slots.

```
$ python -m rollingmill.tools init # seed with a 6mm end mill, 3mm ball nose, 60° V-bit
$ python -m rollingmill.tools list
$ python -m rollingmill.tools show ball_3mm
```

Emitters accept `--tool <id>` to pull defaults for `--feed`, `--plunge-feed` and `--spindle-rpm` (and for `totem`, `--tool-diameter`). Explicit flags always override the tool's defaults. The file is plain JSON and can be hand-edited; the `relief3D` carver requires `--tool` since it needs the cutter's tip shape for collision-free path planning.


Engraving text
-----

`rollingmill.text_to_gcode` renders a text string to a G-code file using a Hershey stroke font from the `pyhershey` library. Glyphs carry per-character advance-width metrics, so spacing is naturally kerned. The output is plain RS-274 intended to be streamed via the TUI's `--file` flow.
`rollingmill.text` renders a text string to a G-code file using a Hershey stroke font from the `pyhershey` library. Glyphs carry per-character advance-width metrics, so spacing is naturally kerned. The output is plain RS-274 intended to be streamed via the TUI's `--file` flow.

```
python -m rollingmill.text_to_gcode \
python -m rollingmill.text \
--text "Parcels" \
--out /tmp/label.gcode \
--height 8 \
Expand Down Expand Up @@ -216,10 +232,45 @@ Then load on the machine: `rollingmill --file /tmp/spiral.gcode`.

![Drawing spirals](docs/screenshots/spirals.png)

Cutting holes
-----

`rollingmill.cuthole` mills a round through-hole of a given outer diameter in thin sheet, centred on the workspace origin, leaving a few break-off tabs so the disc stays captive until you finish it with a scalpel/craft knife. The tool follows the *inside* of the hole (cutter-centre radius `OD/2 − tool_dia/2`), so the finished hole diameter equals `--od`.

```
python -m rollingmill.cuthole \
--od 47 \
--out /tmp/hole.gcode \
--tool-dia 5 \
--thickness 1.5 \
--tabs 4 --tab-frac 0.05 \
--z-step 0.1 \
--workspace G54
```

`--od` is the finished hole diameter in mm; `--tool-dia` is the end-mill diameter (default 5, or pulled from `--tool`). The cut ramps down in flat passes of `--z-step` mm (default 0.1) until it breaks through `--thickness` (default 1.5) plus a small `--overcut` (default 0.1) into the backing. `--tabs` sets the number of evenly spaced tabs (default 4, at N/S/E/W) and `--tab-frac` their width as a fraction of the OD (default 0.05 = 5%); tabs are left at full thickness, the tool lifting clear over each one on every pass. Pass `--x0`/`--y0` if the hole centre isn't the work-offset origin.

With `--helix` the tool descends continuously along the arcs (one `--z-step` of pitch per revolution) instead of stepping in flat passes, finishing with one flat pass at full depth.

Roughing a pocket
-----

`rollingmill.pocket` roughs/flattens a rectangular area down to a given depth, stepping down `--z-step` mm per level. Each level is entered by a helical ramp round the centre (no straight plunge), then spirals outwards in G02/G03 arcs (rounded-rectangle turns spaced `--stepover` × tool diameter apart), then squares the corners with a perimeter pass. The cutter stays inside the rectangle, so `--width` × `--height` is the finished pocket size.

```
python -m rollingmill.pocket \
--width 60 --height 40 --depth 2 \
--out /tmp/pocket.gcode \
--tool-dia 3 --stepover 0.45 --z-step 0.5 \
--workspace G54
```

The workspace origin (X0 Y0 Z0 = top of stock) is the pocket's lower-left corner; pass `--center-origin` if it is the centre instead. Climb milling by default; `--conventional` reverses the spiral.

Drawing mazes
-----

`rollingmill.maze` carves a random rectangular maze using the [`erbsland-maze`](https://pypi.org/project/erbsland-maze/) library, extracts the wall *centerlines* (one stroke per wall, not the outline of a thick wall), and emits a single-pass pen-down/pen-up G-code program. Start- and end-of-path rooms are flagged with small marker squares.
`rollingmill.maze` carves a random rectangular maze using the [`erbsland-maze`](https://pypi.org/project/erbsland-maze/) library, extracts the wall *centerlines* (one stroke per wall, not the outline of a thick wall), and emits a G-code program to scribe the walls. Start- and end-of-path rooms are flagged with small marker squares.

```
python -m rollingmill.maze \
Expand All @@ -238,6 +289,95 @@ Then load on the machine: `rollingmill --file /tmp/maze.gcode`.

![Drawing mazes](docs/screenshots/mazes.png)

3D relief carving
-----

`rollingmill.relief3D` takes a 2D greyscale image and carves it as a depth-modulated relief: white pixels stay at the top of the stock, black pixels are cut down to the deepest Z. The chosen cutter's tip shape (ball-nose dome, V-bit cone, end-mill disc) is convolved into the heightmap so the finishing pass never tries to dive into detail the bit can't physically reach. The result is a single boustrophedon raster.

This is a v1 *finishing pass only* — no roughing, no tool change. Use a ball-nose cutter, and rough out deep regions yourself first if your `--z-min` is more than a millimetre or two below the surface.

Image-carving needs the `relief` extra:

```
pip install "rollingmill[relief]"
```

Then:

```
python -m rollingmill.relief3D \
--image my-photo.png \
--tool ball_3mm \
--stock-width 80 --stock-height 30 \
--z-min -1.5 \
--workspace G54 \
--out /tmp/relief.gcode
```

The image is rescaled to fit `--stock-width × --stock-height` at the resampling pitch (`--pitch`, default = tool radius / 4). `--stepover` controls raster row spacing (default = tool diameter × 0.3). `--gamma` applies a tone curve to the heightmap, and `--invert` flips the brightness-to-depth mapping. Defaults for feed, plunge feed and spindle RPM are pulled from the `--tool` entry.

Then load on the machine: `rollingmill --file /tmp/relief.gcode`.

V-carving an SVG
-----

`rollingmill.vcarve` is a general-purpose SVG → G-code converter for a V-bit. It classifies every shape as either a **filled region** or a **stroke-only path** and carves each the right way. A V-bit's groove width and depth are locked together by its half-angle (`depth = clearance / tan(half_angle)`), so:

* **filled regions** (bold letterforms, solid logos) are carved along their **medial axis** — plunging deeper where the shape is wider, tapering to sharp points at terminals (classic sign-carving). The medial axis is computed **directly from the vector outline** — the interior of the boundary's Voronoi diagram (`--medial-step` sets how finely the outline is sampled, default 0.15 mm) — with no rasterisation. That is robust where a raster skeletoniser fails: it keeps a clean convex glyph like a diamond (◆) as its full cross instead of collapsing it to a dot, and never loses a tiny dot like the one on a colon. Optionally, `--fill-arcs` fits curved medial runs (the bowls of O/C/S/R) to **G02/G03 arcs**, emitted as **helices** (`G0{2,3} X Y Z I J`) where the fill's depth ramps along the curve (`--fill-arc-tol` sets the tolerance); by default fills emit `G01` only. And
* **stroke-only paths** (a logo outline, an arrow) are carved along their **vector centreline** at a *constant* depth read directly from the declared stroke width. Using the vector width — rather than rasterising the stroke — avoids cutting too deep where anti-aliased edges blur the line, or where strokes cross and their filled union looks wider than the line really is. Marker arrowheads (`marker-start`/`marker-end`) are expanded and carved too.

Where a run of a stroke lies on a circle, it is emitted as a native **G02/G03 arc** rather than a chain of short line moves — faster and more accurate on the mill, and a smaller program. Only **curve-origin** geometry (SVG `A` arcs, or Bézier curves, and medial runs) is an arc candidate; straight **line** segments are never arc-fitted, so a sharp corner between two lines (e.g. a triangle vertex) stays a crisp corner instead of being rounded into a tiny arc. `--arc-tol` sets the fitting tolerance (default 0.02 mm) and `--no-arcs` disables it.

A plain centreline groove would round off convex corners (by half the stroke width) and line ends. To recover the sharp features, the stroke's **join and cap style** drives short *sharpening spurs* that ramp the cut to zero depth right at the point: a **miter** join gets a spur along the outer bisector to the miter tip (falling back to **bevel** past `stroke-miterlimit`), **butt**/**square** caps get squared ends, and **round** joins/caps are left as-is (the V-bit already rounds them). So a stroked triangle carves with crisp corners, not radiused ones.

Prep the SVG in Inkscape first: convert text to paths (`Path ▸ Object to Path`), delete any raster tracing images, and set the document size to your workspace. Fills *and* strokes (any colour) are carved; a stroke's depth follows its width, so set line weights deliberately (e.g. 1 mm ≈ 0.87 mm deep with a 60° bit).

V-carving needs the `vcarve` extra (everything is read from the SVG vectors — no Inkscape at run time):

```
pip install "rollingmill[vcarve]"
```

Then:

```
python -m rollingmill.vcarve \
--svg drawing.svg \
--tool vbit_60 \
--page-w 300 --page-h 300 \
--max-depth 3 --stepdown 0.6 --min-depth 0.3 \
--workspace G54 \
--out /tmp/vcarve.gcode
```

The page is mapped 1:1 onto the bed with the SVG's top-left at `(--x0, --y0)` and the Y axis flipped to the machine's front-left origin. `--medial-step` sets how finely a filled outline is sampled for the Voronoi medial (mm, default 0.15); finer resolves narrow necks and small counters at the cost of speed. Depth is clamped to `--max-depth` and reached in `--stepdown` roughing passes; `--min-depth` floors faint filled grooves without blunting the sharp letter terminals (it does not affect strokes, whose depth is exact from their width). `--stroke-flatten` controls how finely curved strokes are sampled, and `--no-markers` skips arrowhead expansion. `--preview` writes **two** companion files: a carved-surface **PNG** — modelling the swath the V-bit actually removes (a cone of radius `depth × tan(half_angle)` around each toolpath), colouring every pixel by the *deepest* cut reaching it (deep = blue, shallow = pale), supersampled and antialiased — and a vector **SVG** of the recovered toolpath centrelines, each a thin depth-coloured line (tool placement vs the PNG's removed area). Both are in the drawing's orientation; they default to `<out>.png`/`<out>.svg`, or pass `--preview path.png`. `--preview-pitch` sets the PNG resolution in mm/px (default 0.25 → ~1200 px for a 300 mm page; smaller makes a larger image). `--half-angle`, feeds and RPM default from the `--tool` entry. Pass `--png <mask.png>` instead of `--svg` to medial-axis a pre-rendered raster mask directly (the raster skeletoniser fallback, mainly for testing); `--pitch` then sets its mm/pixel resolution.

Then load on the machine: `rollingmill --file /tmp/vcarve.gcode`.

Turning totems on the rotary axis
-----

`rollingmill.totem` renders a stack of turned-spindle shapes as 4-axis g-code for the MDX-40A with the ZCL-40A rotary attachment. The workpiece extends along **X**, the **A** axis rotates it around X, and each section of the totem is a cross-section shape (cylinder, ellipse, donut, or regular polygon) stacked along the length.

```
python -m rollingmill.totem \
--out /tmp/totem.gcode \
--shape cylinder:10:20 \
--shape hex:12:15 \
--shape donut:14:25 \
--shape square:11:18 \
--shape empty:10 \
--stock-radius 15 --safe-radius 20 \
--tool end_6mm \
--workspace G54
```

Each `--shape NAME:SIZE:LENGTH` stacks one section on the totem in command-line order, starting at `--x0`. `SIZE` is the **circumscribed / maximum radius** in mm (distance from the rotation axis to a polygon corner, or to the cylinder/ellipse outer radius). `LENGTH` is the extent along X in mm. Valid shapes are `cylinder`, `ellipse`, `donut` (a half-sine bead bulging from `size/2` at the ends to `size` at the centre), the regular polygons `triangle` / `square` / `pentagon` / `hex` / `octagon`, and `empty` (a no-cut spacer specified as `empty:LENGTH`).

Round shapes step A finely (`--angle-step`, default 5°) and sweep X back-and-forth at each Z depth. Polygon shapes index A to one position per face and raster the face rectangle in X/Y at descending Z; each face overshoots the inset rectangle in Y toward its next-to-be-cut neighbour to pre-clear the shared corner so no unsupported wedge of stock is left behind, with the per-pass overshoot sized to the cylindrical stock's half-chord at the current Z (full extension at the deepest pass, none near the stock surface). Every A-axis rotation is preceded by a retract to `--safe-radius` (absolute Z clearance, must be ≥ `--stock-radius`) and a return to Y=0 so the tool clears the partly-cut workpiece.

Then load on the machine: `rollingmill --file /tmp/totem.gcode`.

See also
----
* [10maurycy10/gcode2rml](https://github.com/10maurycy10/gcode2rml) can translate g-code to RML
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ dev = [
maze = [
"erbsland-maze>=1.4.8", # imports pycairo, which builds against system cairo on Linux
]
relief = [
"numpy>=1.24",
"pillow>=10.0",
"scipy>=1.10",
]
vcarve = [
"numpy>=1.24",
"pillow>=10.0",
"scipy>=1.10",
"scikit-image>=0.22",
"svgelements>=1.9",
]

[project.scripts]
rollingmill = "rollingmill.tui:main"
Expand Down
Loading
Loading