diff --git a/.gitignore b/.gitignore index 1c9b2dd..2e2d2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ build/ dist/ *.egg-info/ video/ +manuals +*.bin diff --git a/README.md b/README.md index bc719f5..d1c7429 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` 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 \ @@ -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 \ @@ -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 `.png`/`.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 ` 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 diff --git a/pyproject.toml b/pyproject.toml index 4c73ef3..6e87d4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/rollingmill/cuthole.py b/rollingmill/cuthole.py new file mode 100644 index 0000000..4812ba7 --- /dev/null +++ b/rollingmill/cuthole.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Cut a round through-hole in thin sheet, leaving four break-off tabs. + +Generates MDX-40A g-code that mills a circular hole of a given outer +diameter (OD) with a flat end mill, centred on the workspace origin. The +tool follows the *inside* of the hole, so the cutter-centre path radius is +``OD/2 - tool_dia/2`` and the finished hole diameter equals ``OD``. + +The cut ramps down in flat passes of ``--z-step`` mm until it breaks +through the material (plus a small ``--overcut`` into the backing). With +``--helix`` the tool instead descends continuously along the arcs (one +``--z-step`` of pitch per revolution) and finishes with one flat pass at +full depth. +``--tabs`` evenly spaced tabs (default 4, at N/S/E/W) are left at full +thickness -- the tool lifts clear over each tab on every pass -- so the +disc stays captive until you finish the tabs with a scalpel/craft knife. +Each tab's width is ``--tab-frac`` of the OD (default 5%). + +Emitted as G02/G03-style arcs (I/J centre offsets) suitable for streaming +to the MDX-40A via `rollingmill --file `, matching the style of +``rollingmill.spiral``. +""" + +from __future__ import annotations + +import argparse +import math +import sys +from dataclasses import dataclass +from pathlib import Path + +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + + +@dataclass +class HoleOpts: + od_mm: float # finished hole outer diameter + tool_dia_mm: float = 5.0 # end-mill diameter + thickness_mm: float = 1.5 # material thickness (cut through this) + overcut_mm: float = 0.1 # extra depth past the backing to sever cleanly + z_step_mm: float = 0.1 # depth of cut per pass / helix pitch per rev + helix: bool = False # ramp Z continuously along arcs vs flat steps + n_tabs: int = 4 # number of evenly spaced break-off tabs + tab_frac: float = 0.05 # tab width as a fraction of the OD + x0: float = 0.0 # hole centre X (workspace origin) + y0: float = 0.0 # hole centre Y (workspace origin) + feed: float = 200.0 + plunge_feed: float = 50.0 + z_safe: float = 2.0 + return_home: bool = False + workspace: str | None = None # e.g. "G54".."G59"; emitted in header if set + spindle_rpm: int = 10000 + + +def _fmt(v: float) -> str: + """Format a number with up to 3 decimal places, trimmed.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s if s not in ("", "-0") else "0" + + +def _fmtp(v: float) -> str: + """Format a position value: up to 3 decimal places, always at least 1.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0") + if s.endswith("."): + s += "0" + if s == "-0.0": + s = "0.0" + return s + + +def _point(cx: float, cy: float, r: float, ang: float) -> tuple[float, float]: + return (cx + r * math.cos(ang), cy + r * math.sin(ang)) + + +def emit_gcode(opts: HoleOpts) -> str: + """Render the tabbed hole-cutting program as MDX-40A g-code.""" + if opts.od_mm <= 0: + raise ValueError(f"od_mm={opts.od_mm} must be positive") + if opts.tool_dia_mm <= 0: + raise ValueError(f"tool_dia_mm={opts.tool_dia_mm} must be positive") + if opts.od_mm <= opts.tool_dia_mm: + raise ValueError(f"od_mm={opts.od_mm} must exceed tool_dia_mm={opts.tool_dia_mm}") + if opts.thickness_mm <= 0: + raise ValueError(f"thickness_mm={opts.thickness_mm} must be positive") + if opts.z_step_mm <= 0: + raise ValueError(f"z_step_mm={opts.z_step_mm} must be positive") + if opts.n_tabs < 1: + raise ValueError(f"n_tabs={opts.n_tabs} must be >= 1") + if not (0.0 <= opts.tab_frac < 0.5): + raise ValueError(f"tab_frac={opts.tab_frac} must be in [0, 0.5)") + + cx, cy = opts.x0, opts.y0 + path_r = (opts.od_mm - opts.tool_dia_mm) / 2.0 # cutter-centre radius + + # The circle is split into n_tabs equal segments; each segment is cut + # bar a gap at its start, leaving a full-thickness tab there. Gaps are + # centred at k * seg starting from 0 deg (E) -- so n_tabs=4 lands on + # E/N/W/S. Tab angular width is the tab arc length (tab_frac * OD) + # mapped onto the cutter-centre circle. + seg = 2.0 * math.pi / opts.n_tabs + tab_len = opts.tab_frac * opts.od_mm + tab_ang = tab_len / path_r + if tab_ang >= seg: + raise ValueError( + f"tabs too wide: tab arc {_fmt(tab_len)} mm spans " + f"{_fmt(math.degrees(tab_ang))} deg on the r={_fmt(path_r)} mm " + f"path, but {opts.n_tabs} tabs leave only {_fmt(math.degrees(seg))} " + "deg per segment" + ) + half_tab = tab_ang / 2.0 + + total_depth = opts.thickness_mm + opts.overcut_mm + n_pass = max(1, math.ceil(total_depth / opts.z_step_mm)) + + out: list[str] = [] + w = out.append + + w("(Generated by rollingmill.cuthole -- tabbed through-hole)") + w( + f"(OD: {_fmt(opts.od_mm)} mm, tool dia: {_fmt(opts.tool_dia_mm)} mm, " + f"path radius: {_fmt(path_r)} mm)" + ) + w( + f"(Centre: X{_fmt(cx)} Y{_fmt(cy)}, thickness: {_fmt(opts.thickness_mm)} mm, " + f"overcut: {_fmt(opts.overcut_mm)} mm)" + ) + if opts.helix: + w( + f"(Helix: {n_pass} revs x {_fmt(opts.z_step_mm)} mm pitch to " + f"Z{_fmt(-total_depth)}, plus a flat finishing pass)" + ) + else: + w(f"(Passes: {n_pass} x {_fmt(opts.z_step_mm)} mm to Z{_fmt(-total_depth)})") + w( + f"(Tabs: {opts.n_tabs} evenly spaced, {_fmt(tab_len)} mm wide " + f"= {_fmt(opts.tab_frac * 100)}% of OD, left full thickness)" + ) + w("%") + w("O00000001") + w("G21") # mm + w("G90") # absolute + w("G17") # XY plane + if opts.workspace: + w(opts.workspace) + w(f"F{_fmtp(opts.feed)}") + w(f"S{opts.spindle_rpm}") + w("M03") # spindle on (CW) + w(f"G00 Z{_fmtp(opts.z_safe)}") + + # One cut arc (CCW) per segment, each stopping short of the gap at its + # start so a full-thickness tab remains between it and the previous arc. + segments = [(k * seg + half_tab, (k + 1) * seg - half_tab) for k in range(opts.n_tabs)] + + def flat_pass(z: float, label: str) -> None: + w(label) + for a0, a1 in segments: + sx, sy = _point(cx, cy, path_r, a0) + ex, ey = _point(cx, cy, path_r, a1) + w(f"G00 X{_fmtp(sx)} Y{_fmtp(sy)}") + w(f"G01 Z{_fmtp(z)} F{_fmtp(opts.plunge_feed)}") + w(f"F{_fmtp(opts.feed)}") + # CCW arc; centre offset from the arc start point. + w(f"G03 X{_fmtp(ex)} Y{_fmtp(ey)} I{_fmtp(cx - sx)} J{_fmtp(cy - sy)}") + w(f"G00 Z{_fmtp(opts.z_safe)}") # lift clear of the tab + + if opts.helix: + # Continuous helical descent: Z is a linear function of the angle + # travelled, one z_step per full revolution. The tool still lifts + # over each tab; the helix resumes deeper on the far side, so the + # pitch stays constant and a full-height bridge remains at each tab. + two_pi = 2.0 * math.pi + + def depth(theta: float) -> float: + return -min(opts.z_step_mm * theta / two_pi, total_depth) + + for rev in range(n_pass): + w(f"(rev {rev + 1}/{n_pass})") + for a0, a1 in segments: + z0 = depth(rev * two_pi + a0) + z1 = depth(rev * two_pi + a1) + sx, sy = _point(cx, cy, path_r, a0) + ex, ey = _point(cx, cy, path_r, a1) + w(f"G00 X{_fmtp(sx)} Y{_fmtp(sy)}") + w(f"G01 Z{_fmtp(z0)} F{_fmtp(opts.plunge_feed)}") + w(f"F{_fmtp(opts.feed)}") + # Helical CCW arc: XY arc with a simultaneous linear Z ramp. + w(f"G03 X{_fmtp(ex)} Y{_fmtp(ey)} Z{_fmtp(z1)} I{_fmtp(cx - sx)} J{_fmtp(cy - sy)}") + w(f"G00 Z{_fmtp(opts.z_safe)}") # lift clear of the tab + # Level the ramped floor and guarantee full depth everywhere. + flat_pass(-total_depth, "(finishing pass at full depth)") + else: + for i in range(1, n_pass + 1): + z = -min(i * opts.z_step_mm, total_depth) + flat_pass(z, f"(pass {i}/{n_pass}, Z{_fmt(z)})") + + if opts.return_home: + w("G00 X0.0 Y0.0") + w("M05") + w("M30") + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--od", type=float, required=True, help="Finished hole outer diameter in mm") + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument( + "--tool-dia", + type=float, + default=None, + help="End-mill diameter mm (default 5.0, or from --tool)", + ) + p.add_argument( + "--thickness", + type=float, + default=1.5, + help="Material thickness mm to cut through (default 1.5)", + ) + p.add_argument( + "--overcut", type=float, default=0.1, help="Extra depth past the backing mm (default 0.1)" + ) + p.add_argument( + "--z-step", + type=float, + default=0.1, + help="Depth of cut per pass mm, or helix pitch per revolution with --helix (default 0.1)", + ) + p.add_argument( + "--helix", + action="store_true", + help="Ramp Z continuously down the arcs (helical), one " + "--z-step per revolution, instead of flat stepped " + "passes; adds a flat finishing pass at full depth", + ) + p.add_argument( + "--tabs", type=int, default=4, help="Number of evenly spaced break-off tabs (default 4)" + ) + p.add_argument( + "--tab-frac", + type=float, + default=0.05, + help="Tab width as a fraction of OD (default 0.05 = 5%%)", + ) + p.add_argument("--x0", type=float, default=0.0, help="Hole centre X in mm (default 0.0)") + p.add_argument("--y0", type=float, default=0.0, help="Hole centre Y in mm (default 0.0)") + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 200, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Retract Z height mm (default 2.0)") + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 at end of program") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header (e.g. G54)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed in RPM, emitted with M03 (default 10000, or from --tool)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --tool-dia, --feed, " + "--plunge-feed and --spindle-rpm if those are not set", + ) + args = p.parse_args(argv) + + if args.od <= 0: + p.error(f"--od ({args.od}) must be positive") + + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + + opts = HoleOpts( + od_mm=args.od, + tool_dia_mm=_resolve(args.tool_dia, tool, "diameter_mm", 5.0), + thickness_mm=args.thickness, + overcut_mm=args.overcut, + z_step_mm=args.z_step, + helix=args.helix, + n_tabs=args.tabs, + tab_frac=args.tab_frac, + x0=args.x0, + y0=args.y0, + feed=_resolve(args.feed, tool, "feed_mm_min", 200.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + z_safe=args.z_safe, + return_home=args.return_home, + workspace=args.workspace, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), + ) + try: + gcode = emit_gcode(opts) + except ValueError as e: + p.error(str(e)) + args.out.write_text(gcode) + print(f"wrote {args.out} ({len(gcode.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollingmill/maze.py b/rollingmill/maze.py index 60618f3..5cdf07c 100644 --- a/rollingmill/maze.py +++ b/rollingmill/maze.py @@ -11,6 +11,7 @@ emitters in this package, with a single pen-down / pen-up cycle per merged wall polyline. """ + from __future__ import annotations import argparse @@ -38,6 +39,9 @@ "rollingmill.maze needs the 'maze' extra: pip install 'rollingmill[maze]'" ) from exc +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + ENDPOINT_MODES = ("none", "outline", "filled") @@ -49,9 +53,9 @@ class MazeOpts: cell: float = 4.0 wall: float = 1.7 seed: int | None = None - endpoints: str = "outline" # one of ENDPOINT_MODES - endpoint_size: float = 1.5 # mm, side length of marker square - endpoint_fill_pitch: float = 0.4 # mm, zig-zag pitch for "filled" + endpoints: str = "outline" # one of ENDPOINT_MODES + endpoint_size: float = 1.5 # mm, side length of marker square + endpoint_fill_pitch: float = 0.4 # mm, zig-zag pitch for "filled" x0: float = 0.0 y0: float = 0.0 feed: float = 200.0 @@ -59,7 +63,7 @@ class MazeOpts: z_safe: float = 2.0 z_cut: float = -0.1 return_home: bool = False - workspace: str | None = None # e.g. "G54".."G59"; emitted in header + workspace: str | None = None # e.g. "G54".."G59"; emitted in header spindle_rpm: int = 10000 @@ -97,9 +101,7 @@ def _wall_endpoints(layout: SvgLayout, wall: Wall) -> tuple[Point, Point]: return rect.top_left, rect.bottom_left -def _extract_centerlines( - rooms: Iterable[Room], layout: SvgLayout -) -> list[PolyLine]: +def _extract_centerlines(rooms: Iterable[Room], layout: SvgLayout) -> list[PolyLine]: """Collect deduped wall centerlines and merge them into polylines. Rooms are sorted by location because `RoomGrid.get_all_rooms()` returns a @@ -140,6 +142,7 @@ def _generate(opts: MazeOpts) -> tuple[SvgLayout, Generator]: gen.prepare_rooms() # Mirror the retry loop in Generator.generate_and_save without writing SVG. from erbsland_maze.generator_error import NoValidSolutionError + for attempt in range(gen.setup.maximum_attempts): try: gen.generate_maze() @@ -236,9 +239,7 @@ def emit_gcode(opts: MazeOpts) -> str: if opts.cell <= 0 or opts.wall <= 0: raise ValueError("cell and wall must be positive") if opts.endpoints not in ENDPOINT_MODES: - raise ValueError( - f"endpoints={opts.endpoints!r} must be one of {ENDPOINT_MODES}" - ) + raise ValueError(f"endpoints={opts.endpoints!r} must be one of {ENDPOINT_MODES}") seed = opts.seed if opts.seed is not None else random.randrange(2**32) opts_resolved = MazeOpts(**{**opts.__dict__, "seed": seed}) @@ -251,8 +252,10 @@ def emit_gcode(opts: MazeOpts) -> str: w = out.append w("(Generated by rollingmill.maze)") - w(f"(Size: {_fmt(opts.width)} x {_fmt(opts.height)} mm, " - f"cell: {_fmt(opts.cell)} mm, wall: {_fmt(opts.wall)} mm)") + w( + f"(Size: {_fmt(opts.width)} x {_fmt(opts.height)} mm, " + f"cell: {_fmt(opts.cell)} mm, wall: {_fmt(opts.wall)} mm)" + ) w(f"(Seed: {seed}, endpoints: {opts.endpoints})") w("%") w("O00000001") @@ -286,55 +289,94 @@ def emit_gcode(opts: MazeOpts) -> str: def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - p.add_argument("--out", type=Path, required=True, - help="Output .gcode file path") - p.add_argument("--width", type=float, default=100.0, - help="Canvas width in mm (default 100)") - p.add_argument("--height", type=float, default=100.0, - help="Canvas height in mm (default 100)") - p.add_argument("--cell", type=float, default=4.0, - help="Cell side length in mm, including wall (default 4.0)") - p.add_argument("--wall", type=float, default=1.7, - help="Wall thickness in mm (default 1.7)") - p.add_argument("--seed", type=int, default=None, - help="Random seed (default: random; echoed in header)") - p.add_argument("--endpoints", choices=ENDPOINT_MODES, default="outline", - help="Endpoint marker style (default outline)") - p.add_argument("--endpoint-size", type=float, default=1.5, - help="Endpoint marker side length in mm (default 1.5)") - p.add_argument("--x0", type=float, default=0.0, - help="X offset of bottom-left corner in mm (default 0.0)") - p.add_argument("--y0", type=float, default=0.0, - help="Y offset of bottom-left corner in mm (default 0.0)") - p.add_argument("--feed", type=float, default=200.0, - help="Cutting feed mm/min (default 200)") - p.add_argument("--plunge-feed", type=float, default=50.0, - help="Z plunge feed mm/min (default 50)") - p.add_argument("--z-safe", type=float, default=2.0, - help="Pen-up Z height mm (default 2.0)") - p.add_argument("--z-cut", type=float, default=-0.1, - help="Pen-down Z depth mm (default -0.1)") - p.add_argument("--return-home", action="store_true", - help="Append G00 X0 Y0 at end of program") - p.add_argument("--workspace", default=None, - choices=["G54", "G55", "G56", "G57", "G58", "G59"], - help="Workspace offset to select in header (e.g. G54)") - p.add_argument("--spindle-rpm", type=int, default=10000, - help="Spindle speed in RPM, emitted with M03 (default 10000)") + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument("--width", type=float, default=100.0, help="Canvas width in mm (default 100)") + p.add_argument("--height", type=float, default=100.0, help="Canvas height in mm (default 100)") + p.add_argument( + "--cell", + type=float, + default=4.0, + help="Cell side length in mm, including wall (default 4.0)", + ) + p.add_argument("--wall", type=float, default=1.7, help="Wall thickness in mm (default 1.7)") + p.add_argument( + "--seed", type=int, default=None, help="Random seed (default: random; echoed in header)" + ) + p.add_argument( + "--endpoints", + choices=ENDPOINT_MODES, + default="outline", + help="Endpoint marker style (default outline)", + ) + p.add_argument( + "--endpoint-size", + type=float, + default=1.5, + help="Endpoint marker side length in mm (default 1.5)", + ) + p.add_argument( + "--x0", type=float, default=0.0, help="X offset of bottom-left corner in mm (default 0.0)" + ) + p.add_argument( + "--y0", type=float, default=0.0, help="Y offset of bottom-left corner in mm (default 0.0)" + ) + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 200, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Pen-up Z height mm (default 2.0)") + p.add_argument("--z-cut", type=float, default=-0.1, help="Pen-down Z depth mm (default -0.1)") + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 at end of program") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header (e.g. G54)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed in RPM, emitted with M03 (default 10000, or from --tool)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --feed, --plunge-feed and " + "--spindle-rpm if those are not set", + ) args = p.parse_args(argv) + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + opts = MazeOpts( - width=args.width, height=args.height, - cell=args.cell, wall=args.wall, + width=args.width, + height=args.height, + cell=args.cell, + wall=args.wall, seed=args.seed, endpoints=args.endpoints, endpoint_size=args.endpoint_size, - x0=args.x0, y0=args.y0, - feed=args.feed, plunge_feed=args.plunge_feed, - z_safe=args.z_safe, z_cut=args.z_cut, + x0=args.x0, + y0=args.y0, + feed=_resolve(args.feed, tool, "feed_mm_min", 200.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + z_safe=args.z_safe, + z_cut=args.z_cut, return_home=args.return_home, workspace=args.workspace, - spindle_rpm=args.spindle_rpm, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), ) gcode = emit_gcode(opts) args.out.write_text(gcode) diff --git a/rollingmill/pocket.py b/rollingmill/pocket.py new file mode 100644 index 0000000..1443a43 --- /dev/null +++ b/rollingmill/pocket.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Rough/flatten a rectangular pocket, spiralling out from the centre in arcs. + +Generates MDX-40A g-code that clears a ``--width`` x ``--height`` rectangle +down to Z=-``--depth`` with a flat end mill, in steps of ``--z-step`` mm. +The workspace origin (X0 Y0 Z0, top of stock) is the lower-left corner of the +rectangle by default, or its centre with ``--center-origin``. + +Each depth level is entered by a helical ramp round the centre (no plunge) and +then spirals outwards. Every turn of +the spiral is a rounded rectangle (straight runs joined by G02/G03 quarter +arcs) that is ``--stepover`` (fraction of tool diameter) further out than the +last, growing from the rectangle's medial axis, so the cut stays an even +width along the straights. A final sharp-cornered perimeter pass squares up +the corners. The pocket is the finished size: the cutter centre stays +``tool_dia/2`` inside the rectangle. + +Cuts are climb-milled (clockwise with a clockwise spindle); pass +``--conventional`` to reverse. Output suits `rollingmill --file `. +""" + +from __future__ import annotations + +import argparse +import math +import sys +from dataclasses import dataclass +from pathlib import Path + +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + +_EPS = 1e-6 + + +@dataclass +class PocketOpts: + width_mm: float # pocket size along X + height_mm: float # pocket size along Y + depth_mm: float # total depth below Z0 + tool_dia_mm: float = 3.0 + stepover: float = 0.45 # radial step as a fraction of tool diameter + z_step_mm: float = 0.5 # depth of cut per level + center_origin: bool = False # origin at pocket centre instead of lower-left + x0: float = 0.0 # extra offset applied to the origin + y0: float = 0.0 + conventional: bool = False # conventional instead of climb milling + feed: float = 300.0 + plunge_feed: float = 50.0 + z_safe: float = 2.0 + return_home: bool = False + workspace: str | None = None # e.g. "G54".."G59"; emitted in header if set + spindle_rpm: int = 10000 + tool_label: str | None = None # library tool id/name, noted in the header + + +def _fmt(v: float) -> str: + """Format a number with up to 3 decimal places, trimmed.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s if s not in ("", "-0") else "0" + + +def _fmtp(v: float) -> str: + """Format a position value: up to 3 decimal places, always at least 1.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0") + if s.endswith("."): + s += "0" + if s == "-0.0": + s = "0.0" + return s + + +def _radii(half_short: float, step: float) -> list[float]: + """Offsets from the medial axis: step/2, 3*step/2, ... ending exactly at half_short. + + Starting at half a step puts the two innermost rows (either side of the + axis) one step apart like every other pair of neighbouring rows. A turn + that would sit within a quarter step of the last one is dropped. + """ + out: list[float] = [] + r = step / 2.0 + while r < half_short - 0.25 * step: + out.append(r) + r += step + out.append(half_short) + return out + + +def emit_gcode(opts: PocketOpts) -> str: + """Render the spiral pocketing program as MDX-40A g-code.""" + if opts.tool_dia_mm <= 0: + raise ValueError(f"tool_dia_mm={opts.tool_dia_mm} must be positive") + if opts.depth_mm <= 0: + raise ValueError(f"depth_mm={opts.depth_mm} must be positive") + if opts.z_step_mm <= 0: + raise ValueError(f"z_step_mm={opts.z_step_mm} must be positive") + if not (0.0 < opts.stepover <= 1.0): + raise ValueError(f"stepover={opts.stepover} must be in (0, 1]") + if opts.width_mm <= opts.tool_dia_mm or opts.height_mm <= opts.tool_dia_mm: + raise ValueError( + f"pocket {_fmt(opts.width_mm)} x {_fmt(opts.height_mm)} mm must exceed " + f"tool_dia_mm={_fmt(opts.tool_dia_mm)} in both directions" + ) + + pitch = opts.stepover * opts.tool_dia_mm + cx = opts.x0 + (0.0 if opts.center_origin else opts.width_mm / 2.0) + cy = opts.y0 + (0.0 if opts.center_origin else opts.height_mm / 2.0) + + # Work in local (u, v) coords with u along the long axis, centred on the + # pocket. Cutter-centre rectangle has half-sizes A >= B. + swap = opts.height_mm > opts.width_mm + A = (max(opts.width_mm, opts.height_mm) - opts.tool_dia_mm) / 2.0 + B = (min(opts.width_mm, opts.height_mm) - opts.tool_dia_mm) / 2.0 + # Climb milling in a pocket is clockwise; the local path is built CCW, so + # mirror v. Swapping u/v is also a reflection, so track the parity. + mirror = not opts.conventional + ccw = (mirror + swap) % 2 == 0 + corner_cap = opts.tool_dia_mm # keeps the gap to the sharp corner cleared + + def world(u: float, v: float) -> tuple[float, float]: + if mirror: + v = -v + return (cx + v, cy + u) if swap else (cx + u, cy + v) + + arc_cmd = "G03" if ccw else "G02" + total_levels = max(1, math.ceil(opts.depth_mm / opts.z_step_mm - _EPS)) + rhos = _radii(B, pitch) + + out: list[str] = [] + w = out.append + + def line(u: float, v: float) -> None: + x, y = world(u, v) + w(f"G01 X{_fmtp(x)} Y{_fmtp(y)}") + + def arc( + u0: float, v0: float, u1: float, v1: float, uc: float, vc: float, z: float | None = None + ) -> None: + x0, y0 = world(u0, v0) + x1, y1 = world(u1, v1) + xc, yc = world(uc, vc) + zw = "" if z is None else f" Z{_fmtp(z)}" + w(f"{arc_cmd} X{_fmtp(x1)} Y{_fmtp(y1)}{zw} I{_fmtp(xc - x0)} J{_fmtp(yc - y0)}") + + def rounded_loop(rho: float) -> None: + """One turn round the rounded rectangle, ending at (0, -rho). + + Arriving from the previous turn at (0, -b_prev), the first move is a + slanted line to the start of this turn's first corner, so the tool + keeps moving along the long axis while stepping out rather than + stopping for a sideways jog (it degenerates to a radial step only + where the straight is zero-length, i.e. circular turns). + """ + a, b = (A - B) + rho, rho + c = min(rho, corner_cap) + s, t = a - c, b - c # straight-run ends / arc-centre offsets + # (start corner point, end corner point, arc centre) CCW from the bottom edge + corners = [ + ((s, -b), (a, -t), (s, -t)), + ((a, t), (s, b), (s, t)), + ((-s, b), (-a, t), (-s, t)), + ((-a, -t), (-s, -b), (-s, -t)), + ] + for (u0, v0), (u1, v1), (uc, vc) in corners: + if math.hypot(u0 - cur[0], v0 - cur[1]) > _EPS: + line(u0, v0) + if c > _EPS: + arc(u0, v0, u1, v1, uc, vc) + else: + line(u1, v1) + cur[0], cur[1] = u1, v1 + if abs(cur[0]) > _EPS or abs(cur[1] + b) > _EPS: + line(0.0, -b) + cur[0], cur[1] = 0.0, -b + + cur = [0.0, 0.0] + + w("(Generated by rollingmill.pocket -- spiral-out roughing pocket)") + tool_src = f"tool {opts.tool_label}" if opts.tool_label else "tool (no library entry)" + w( + f"({tool_src}: dia {_fmt(opts.tool_dia_mm)} mm, radius {_fmt(opts.tool_dia_mm / 2)} mm, " + f"feed {_fmt(opts.feed)}, plunge {_fmt(opts.plunge_feed)} mm/min, S{opts.spindle_rpm})" + ) + w( + f"(Pocket: {_fmt(opts.width_mm)} x {_fmt(opts.height_mm)} mm, " + f"depth: {_fmt(opts.depth_mm)} mm)" + ) + w( + f"(Centre: X{_fmt(cx)} Y{_fmt(cy)}, stepover: {_fmt(pitch)} mm " + f"= {_fmt(opts.stepover * 100)}% of tool, {len(rhos)} turns per level)" + ) + w( + f"(Levels: {total_levels} x {_fmt(opts.z_step_mm)} mm to Z{_fmt(-opts.depth_mm)}, " + f"{'conventional' if opts.conventional else 'climb'}, " + f"helical entry r={_fmt(rhos[0])} mm)" + ) + w("%") + w("O00000001") + w("G21") # mm + w("G90") # absolute + w("G17") # XY plane + if opts.workspace: + w(opts.workspace) + w(f"F{_fmtp(opts.feed)}") + w(f"S{opts.spindle_rpm}") + w("M03") # spindle on (CW) + w(f"G00 Z{_fmtp(opts.z_safe)}") + + # Each level is entered by one helical revolution (two half-arcs, pitch + # = the level's depth increment) round the pocket centre at the first + # spiral radius, so the spiral then starts at full depth with no plunge. + r_h = rhos[0] + z_prev = 0.0 + for i in range(1, total_levels + 1): + z = -min(i * opts.z_step_mm, opts.depth_mm) + w(f"(level {i}/{total_levels}, Z{_fmt(z)})") + x, y = world(0.0, -r_h) + w(f"G00 X{_fmtp(x)} Y{_fmtp(y)}") + w(f"G01 Z{_fmtp(z_prev)} F{_fmtp(opts.plunge_feed)}") # to previous floor + w(f"F{_fmtp(opts.feed)}") + arc(0.0, -r_h, 0.0, r_h, 0.0, 0.0, (z_prev + z) / 2.0) + arc(0.0, r_h, 0.0, -r_h, 0.0, 0.0, z) + z_prev = z + cur[0], cur[1] = 0.0, -r_h + for rho in rhos: + rounded_loop(rho) + # Sharp-cornered perimeter pass to square up the corners. + for u, v in [(A, -B), (A, B), (-A, B), (-A, -B), (0.0, -B)]: + line(u, v) + w(f"G00 Z{_fmtp(opts.z_safe)}") + + if opts.return_home: + w("G00 X0.0 Y0.0") + w("M05") + w("M30") + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--width", type=float, required=True, help="Pocket size along X in mm") + p.add_argument("--height", type=float, required=True, help="Pocket size along Y in mm") + p.add_argument( + "--depth", type=float, required=True, help="Total depth below Z0 in mm (positive)" + ) + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument( + "--tool-dia", + type=float, + default=None, + help="End-mill diameter mm (default 3.0, or from --tool)", + ) + p.add_argument( + "--stepover", + type=float, + default=0.45, + help="Radial step between spiral turns as a fraction of tool diameter (default 0.45)", + ) + p.add_argument( + "--z-step", type=float, default=0.5, help="Depth of cut per level mm (default 0.5)" + ) + p.add_argument( + "--center-origin", + action="store_true", + help="Workspace origin is the pocket centre (default: its lower-left corner)", + ) + p.add_argument("--x0", type=float, default=0.0, help="Origin offset in X mm (default 0.0)") + p.add_argument("--y0", type=float, default=0.0, help="Origin offset in Y mm (default 0.0)") + p.add_argument( + "--conventional", + action="store_true", + help="Conventional instead of climb milling (reverses spiral direction)", + ) + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 300, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Retract Z height mm (default 2.0)") + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 at end of program") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header (e.g. G54)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed in RPM, emitted with M03 (default 10000, or from --tool)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --tool-dia, --feed, " + "--plunge-feed and --spindle-rpm if those are not set", + ) + args = p.parse_args(argv) + + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + + opts = PocketOpts( + width_mm=args.width, + height_mm=args.height, + depth_mm=args.depth, + tool_dia_mm=_resolve(args.tool_dia, tool, "diameter_mm", 3.0), + stepover=args.stepover, + z_step_mm=args.z_step, + center_origin=args.center_origin, + x0=args.x0, + y0=args.y0, + conventional=args.conventional, + feed=_resolve(args.feed, tool, "feed_mm_min", 300.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + z_safe=args.z_safe, + return_home=args.return_home, + workspace=args.workspace, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), + tool_label=None if tool is None else f"{tool.id} - {tool.name}", + ) + try: + gcode = emit_gcode(opts) + except ValueError as e: + p.error(str(e)) + args.out.write_text(gcode) + print(f"wrote {args.out} ({len(gcode.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollingmill/relief3D.py b/rollingmill/relief3D.py new file mode 100644 index 0000000..95d191c --- /dev/null +++ b/rollingmill/relief3D.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Render a greyscale image as a 3D relief carving in single-pass g-code. + +The image's brightness is treated as height: white = top of stock, black = +deepest cut. The chosen cutter's tip shape eats into a per-pixel "reachable +surface" via a morphological erosion (so a wide ball nose cannot dive into +fine detail it physically cannot reach), and the result is rastered with a +boustrophedon pattern. + +This is v1: finishing pass only, single tool, no roughing and no tool +change. Use a ball-nose cutter unless you understand the cusp behaviour of +end mills on slopes. + +Streamed via the existing ``rollingmill --file out.gcode`` TUI flow. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from rollingmill import tools as _tools + + +_RELIEF_EXTRA_HINT = ( + "rollingmill.relief3D needs numpy, pillow and scipy. Install them with:\n" + " pip install rollingmill[relief]\n" + "or manually:\n" + " pip install numpy pillow scipy" +) + + +def _require_deps(): + try: + import numpy as np + from PIL import Image + from scipy.ndimage import gaussian_filter, grey_dilation + except ImportError as e: + raise ImportError(_RELIEF_EXTRA_HINT) from e + return np, Image, gaussian_filter, grey_dilation + + +@dataclass +class ReliefOpts: + image: Path + out: Path + tool: str + stock_width_mm: float + stock_height_mm: float + z_max: float = 0.0 + z_min: float = -3.0 + gamma: float = 1.0 + pixel_pitch_mm: float | None = None # default: tool radius / 4 + stepover_mm: float | None = None # default: tool diameter * 0.3 + blur_px: float = 0.5 # gaussian σ in resampled pixels + x0: float = 0.0 + y0: float = 0.0 + z_safe: float = 2.0 + feed: float | None = None # default: from tool, else 400 + plunge_feed: float | None = None # default: from tool, else 80 + spindle_rpm: int | None = None # default: from tool, else 14000 + workspace: str | None = None + return_home: bool = False + raster_axis: Literal["x", "y"] = "x" + direction: Literal["zigzag", "oneway"] = "zigzag" + invert: bool = False + + +def _fmt(v: float) -> str: + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s if s not in ("", "-0") else "0" + + +def _fmtp(v: float) -> str: + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0") + if s.endswith("."): + s += "0" + if s == "-0.0": + s = "0.0" + return s + + +def _resolved_pitch(opts: ReliefOpts, tool: _tools.Tool) -> float: + if opts.pixel_pitch_mm is not None: + return opts.pixel_pitch_mm + return max(tool.diameter_mm / 2.0 / 4.0, 0.05) + + +def _resolved_stepover(opts: ReliefOpts, tool: _tools.Tool) -> float: + if opts.stepover_mm is not None: + return opts.stepover_mm + return max(tool.diameter_mm * 0.3, 0.05) + + +def load_heightmap(opts: ReliefOpts, pitch_mm: float): + """Image -> 2D numpy array of mill-space Z values. + + Returns an array indexed ``H[y_idx, x_idx]`` where ``y_idx = 0`` is the + *bottom* row in mill coords (Y+ up). Z values lie in + ``[z_min, z_max]``. + """ + np, Image, gaussian_filter, _ = _require_deps() + if opts.z_max <= opts.z_min: + raise ValueError("z_max must be > z_min") + if opts.stock_width_mm <= 0 or opts.stock_height_mm <= 0: + raise ValueError("stock_width_mm and stock_height_mm must be > 0") + if opts.gamma <= 0: + raise ValueError("gamma must be > 0") + + img = Image.open(opts.image).convert("L") + nx = max(2, int(round(opts.stock_width_mm / pitch_mm))) + ny = max(2, int(round(opts.stock_height_mm / pitch_mm))) + img = img.resize((nx, ny), Image.LANCZOS) + + a = np.asarray(img, dtype=float) / 255.0 # (ny, nx), row 0 = top + if opts.blur_px > 0: + a = gaussian_filter(a, sigma=opts.blur_px) + a = np.clip(a, 0.0, 1.0) ** opts.gamma + + if opts.invert: + intensity = 1.0 - a + else: + intensity = a + + z = opts.z_min + intensity * (opts.z_max - opts.z_min) + # PIL row 0 is the image top; mill Y+ is up, so flip so array row 0 is + # the bottom of the stock. + z = z[::-1, :] + return z + + +def erode_by_tool(heightmap, tool: _tools.Tool, pitch_mm: float): + """Apply tool-tip morphology so reach(x,y) is the deepest tip Z that does + not gouge anywhere in the tool's footprint.""" + np, _, _, grey_dilation = _require_deps() + fp = tool.footprint(pitch_mm) + mask = ~np.isinf(fp) + if not mask.any(): + raise ValueError("tool footprint is empty at this pitch") + structure = np.where(mask, -fp, 0.0) + # reach(x,y) = max over (dx,dy) in mask of [H(x+dx, y+dy) - fp(dx,dy)] + return grey_dilation(heightmap, footprint=mask, structure=structure) + + +def raster_path(reach_map, opts: ReliefOpts, pitch_mm: float, stepover_mm: float): + """Build a single boustrophedon polyline across the reach map. + + Returns a list of ``(x_mm, y_mm, z_mm)`` tuples in mill coords (bottom- + left origin offset by ``opts.x0, opts.y0``). + """ + np, _, _, _ = _require_deps() + ny, nx = reach_map.shape + + # Sample stride in pixels for the stepover; clamp to at least 1. + row_stride_px = max(1, int(round(stepover_mm / pitch_mm))) + row_indices = list(range(0, ny, row_stride_px)) + if row_indices[-1] != ny - 1: + row_indices.append(ny - 1) + + xs_mm = np.arange(nx) * pitch_mm + opts.x0 + poly: list[tuple[float, float, float]] = [] + forward = True + for r_idx, r in enumerate(row_indices): + y_mm = opts.y0 + r * pitch_mm + zs = reach_map[r, :] + col_iter = range(nx) if forward else range(nx - 1, -1, -1) + for c in col_iter: + poly.append((float(xs_mm[c]), float(y_mm), float(zs[c]))) + if opts.direction == "zigzag": + forward = not forward + return poly + + +def emit_gcode(opts: ReliefOpts) -> str: + """Run the full pipeline and return RS-274 g-code as a string.""" + if not opts.image.exists(): + raise FileNotFoundError(f"image not found: {opts.image}") + + tool = _tools.find(_tools.load(), opts.tool) + pitch = _resolved_pitch(opts, tool) + stepover = _resolved_stepover(opts, tool) + + feed = _tools.resolve(opts.feed, tool, "feed_mm_min", 400.0) + plunge_feed = _tools.resolve(opts.plunge_feed, tool, "plunge_feed_mm_min", 80.0) + spindle_rpm = _tools.resolve(opts.spindle_rpm, tool, "spindle_rpm", 14000) + + heightmap = load_heightmap(opts, pitch) + reach_map = erode_by_tool(heightmap, tool, pitch) + poly = raster_path(reach_map, opts, pitch, stepover) + + out: list[str] = [] + w = out.append + + w("(Generated by rollingmill.relief3D)") + w( + f"(Image: {opts.image.name}, stock: " + f"{_fmt(opts.stock_width_mm)} x {_fmt(opts.stock_height_mm)} mm)" + ) + w(f"(Tool: {tool.id} ({tool.type}, dia {_fmt(tool.diameter_mm)} mm))") + w(f"(Pitch: {_fmt(pitch)} mm, stepover: {_fmt(stepover)} mm)") + w(f"(Z range: {_fmt(opts.z_min)} .. {_fmt(opts.z_max)} mm)") + w("%") + w("O00000001") + w("G21") + w("G90") + w("G17") + if opts.workspace: + w(opts.workspace) + w(f"F{_fmtp(feed)}") + w(f"S{spindle_rpm}") + w("M03") + w(f"G00 Z{_fmtp(opts.z_safe)}") + + if poly: + x0, y0, z0 = poly[0] + w(f"G00 X{_fmtp(x0)} Y{_fmtp(y0)}") + w(f"G01 Z{_fmtp(z0)} F{_fmtp(plunge_feed)}") + w(f"F{_fmtp(feed)}") + prev = poly[0] + for x, y, z in poly[1:]: + parts = ["G01"] + if x != prev[0]: + parts.append(f"X{_fmtp(x)}") + if y != prev[1]: + parts.append(f"Y{_fmtp(y)}") + if z != prev[2]: + parts.append(f"Z{_fmtp(z)}") + if len(parts) > 1: + w(" ".join(parts)) + prev = (x, y, z) + + w(f"G00 Z{_fmtp(opts.z_safe)}") + if opts.return_home: + w("G00 X0.0 Y0.0") + w("M05") + w("M30") + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--image", type=Path, required=True, help="Input greyscale image (PNG/JPG/...)") + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument("--tool", required=True, help="Tool id or name from ~/.rollingmill/tools.json") + p.add_argument("--stock-width", type=float, required=True, help="Stock X extent in mm") + p.add_argument("--stock-height", type=float, required=True, help="Stock Y extent in mm") + p.add_argument( + "--z-max", type=float, default=0.0, help="Top-of-stock Z mm (white pixel; default 0.0)" + ) + p.add_argument( + "--z-min", type=float, default=-3.0, help="Deepest cut Z mm (black pixel; default -3.0)" + ) + p.add_argument( + "--gamma", type=float, default=1.0, help="Tone curve on the heightmap (default 1.0)" + ) + p.add_argument( + "--pitch", + type=float, + default=None, + help="Image resample pitch mm (default: tool radius / 4)", + ) + p.add_argument( + "--stepover", + type=float, + default=None, + help="Raster row spacing mm (default: tool dia * 0.3)", + ) + p.add_argument( + "--blur", + type=float, + default=0.5, + help="Gaussian blur sigma in pixels (default 0.5; 0 disables)", + ) + p.add_argument("--invert", action="store_true", help="Black=top instead of black=deepest") + p.add_argument( + "--x0", type=float, default=0.0, help="X offset of bottom-left of stock mm (default 0)" + ) + p.add_argument( + "--y0", type=float, default=0.0, help="Y offset of bottom-left of stock mm (default 0)" + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Rapid Z height mm (default 2.0)") + p.add_argument( + "--feed", + type=float, + default=None, + help="Cutting feed mm/min (default from --tool, else 400)", + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default from --tool, else 80)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle RPM (default from --tool, else 14000)", + ) + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header", + ) + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 before M05") + p.add_argument( + "--raster-axis", choices=["x", "y"], default="x", help="Raster direction (default x)" + ) + p.add_argument( + "--direction", + choices=["zigzag", "oneway"], + default="zigzag", + help="Boustrophedon zigzag or one-way (default zigzag)", + ) + args = p.parse_args(argv) + + opts = ReliefOpts( + image=args.image, + out=args.out, + tool=args.tool, + stock_width_mm=args.stock_width, + stock_height_mm=args.stock_height, + z_max=args.z_max, + z_min=args.z_min, + gamma=args.gamma, + pixel_pitch_mm=args.pitch, + stepover_mm=args.stepover, + blur_px=args.blur, + invert=args.invert, + x0=args.x0, + y0=args.y0, + z_safe=args.z_safe, + feed=args.feed, + plunge_feed=args.plunge_feed, + spindle_rpm=args.spindle_rpm, + workspace=args.workspace, + return_home=args.return_home, + raster_axis=args.raster_axis, + direction=args.direction, + ) + + try: + gcode = emit_gcode(opts) + except (FileNotFoundError, KeyError, ValueError) as e: + p.error(str(e)) + except ImportError as e: + print(str(e), file=sys.stderr) + return 2 + + args.out.write_text(gcode) + print(f"wrote {args.out} ({len(gcode.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollingmill/rml.py b/rollingmill/rml.py index 0905115..9314475 100644 --- a/rollingmill/rml.py +++ b/rollingmill/rml.py @@ -4,6 +4,9 @@ Pure string/bytes construction — no USB, no machine state. Commands are sent via MDX40A.bulk_write() or mdx40a.usb.bulk_write(). +See some examples and links at https://forum.freecad.org/viewtopic.php?t=28684 + + RML-1 reference: ^Z — initialize / soft reset !MC0; — spindle off @@ -17,17 +20,17 @@ def initialize() -> bytes: """Soft reset / initialize.""" - return b'\x1a' + return b"\x1a" def spindle(on: bool) -> bytes: """Turn spindle on (True) or off (False).""" - return b'!MC1;' if on else b'!MC0;' + return b"!MC1;" if on else b"!MC0;" def feed_rate(mm_per_min: float) -> bytes: """Set feed rate in mm/min.""" - return f'V{mm_per_min:.1f};'.encode() + return f"V{mm_per_min:.1f};".encode() def move_abs(x_mm: float, y_mm: float, z_mm: float) -> bytes: @@ -36,9 +39,9 @@ def move_abs(x_mm: float, y_mm: float, z_mm: float) -> bytes: x = round(x_mm * 100) y = round(y_mm * 100) z = round(z_mm * 100) - return f'Z{x},{y},{z};'.encode() + return f"Z{x},{y},{z};".encode() def home() -> bytes: """Return to home position.""" - return b'H;' + return b"H;" diff --git a/rollingmill/spiral.py b/rollingmill/spiral.py index 9790f7e..84cd69d 100644 --- a/rollingmill/spiral.py +++ b/rollingmill/spiral.py @@ -19,6 +19,7 @@ a single pen-down / pen-up cycle centred on (x0, y0), suitable for streaming to the MDX-40A via `rollingmill --file `. """ + from __future__ import annotations import argparse @@ -29,6 +30,9 @@ from pathlib import Path from typing import Iterator +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + @dataclass class SpiralOpts: @@ -69,8 +73,10 @@ def _hypo_point(R: int, r: int, d: float, t: float) -> tuple[float, float]: """Hypotrochoid position at parameter t, in tooth-count units.""" Rmr = R - r ratio = Rmr / r - return (Rmr * math.cos(t) + d * math.cos(ratio * t), - Rmr * math.sin(t) - d * math.sin(ratio * t)) + return ( + Rmr * math.cos(t) + d * math.cos(ratio * t), + Rmr * math.sin(t) - d * math.sin(ratio * t), + ) def _hypo_tangent_unit(R: int, r: int, d: float, t: float) -> tuple[float, float]: @@ -122,9 +128,14 @@ def _fit_tangent_arc( def _arc_max_deviation( - R: int, r: int, d: float, scale: float, - t_start: float, t_end: float, - centre: tuple[float, float], radius: float, + R: int, + r: int, + d: float, + scale: float, + t_start: float, + t_end: float, + centre: tuple[float, float], + radius: float, n_check: int = 5, ) -> float: """Max ||P(t) - centre| - radius| over t in (t_start, t_end), in mm.""" @@ -302,8 +313,7 @@ def emit_gcode(R: int, r: int, d: float, opts: SpiralOpts) -> str: if seg[0] == "arc": _, ex, ey, i_rel, j_rel, cw = seg op = "G02" if cw else "G03" - w(f"{op} X{_fmtp(opts.x0 + ex)} Y{_fmtp(opts.y0 + ey)} " - f"I{_fmtp(i_rel)} J{_fmtp(j_rel)}") + w(f"{op} X{_fmtp(opts.x0 + ex)} Y{_fmtp(opts.y0 + ey)} I{_fmtp(i_rel)} J{_fmtp(j_rel)}") else: # ("line", ex, ey) _, ex, ey = seg w(f"G01 X{_fmtp(opts.x0 + ex)} Y{_fmtp(opts.y0 + ey)}") @@ -318,37 +328,59 @@ def emit_gcode(R: int, r: int, d: float, opts: SpiralOpts) -> str: def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - p.add_argument("--ring", type=int, required=True, - help="Ring (outer fixed gear) tooth count R") - p.add_argument("--rotor", type=int, required=True, - help="Rotor (inner rolling gear) tooth count r") - p.add_argument("--pen", type=float, required=True, - help="Pen arm length d, in the same tooth units as --rotor") - p.add_argument("--radius", type=float, required=True, - help="Desired max radial extent of the pattern in mm") - p.add_argument("--out", type=Path, required=True, - help="Output .gcode file path") - p.add_argument("--x0", type=float, default=0.0, - help="Centre X in mm (default 0.0)") - p.add_argument("--y0", type=float, default=0.0, - help="Centre Y in mm (default 0.0)") - p.add_argument("--chord-tol", type=float, default=0.05, - help="Chord tolerance for sampling, mm (default 0.05)") - p.add_argument("--feed", type=float, default=200.0, - help="Cutting feed mm/min (default 200)") - p.add_argument("--plunge-feed", type=float, default=50.0, - help="Z plunge feed mm/min (default 50)") - p.add_argument("--z-safe", type=float, default=2.0, - help="Pen-up Z height mm (default 2.0)") - p.add_argument("--z-cut", type=float, default=-0.1, - help="Pen-down Z depth mm (default -0.1)") - p.add_argument("--return-home", action="store_true", - help="Append G00 X0 Y0 at end of program") - p.add_argument("--workspace", default=None, - choices=["G54", "G55", "G56", "G57", "G58", "G59"], - help="Workspace offset to select in header (e.g. G54)") - p.add_argument("--spindle-rpm", type=int, default=10000, - help="Spindle speed in RPM, emitted with M03 (default 10000)") + p.add_argument("--ring", type=int, required=True, help="Ring (outer fixed gear) tooth count R") + p.add_argument( + "--rotor", type=int, required=True, help="Rotor (inner rolling gear) tooth count r" + ) + p.add_argument( + "--pen", + type=float, + required=True, + help="Pen arm length d, in the same tooth units as --rotor", + ) + p.add_argument( + "--radius", type=float, required=True, help="Desired max radial extent of the pattern in mm" + ) + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument("--x0", type=float, default=0.0, help="Centre X in mm (default 0.0)") + p.add_argument("--y0", type=float, default=0.0, help="Centre Y in mm (default 0.0)") + p.add_argument( + "--chord-tol", + type=float, + default=0.05, + help="Chord tolerance for sampling, mm (default 0.05)", + ) + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 200, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Pen-up Z height mm (default 2.0)") + p.add_argument("--z-cut", type=float, default=-0.1, help="Pen-down Z depth mm (default -0.1)") + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 at end of program") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header (e.g. G54)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed in RPM, emitted with M03 (default 10000, or from --tool)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --feed, --plunge-feed and " + "--spindle-rpm if those are not set", + ) args = p.parse_args(argv) if args.ring <= args.rotor: @@ -360,15 +392,25 @@ def main(argv: list[str] | None = None) -> int: if args.radius <= 0: p.error(f"--radius ({args.radius}) must be positive") + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + opts = SpiralOpts( radius_mm=args.radius, - x0=args.x0, y0=args.y0, + x0=args.x0, + y0=args.y0, chord_tol_mm=args.chord_tol, - feed=args.feed, plunge_feed=args.plunge_feed, - z_safe=args.z_safe, z_cut=args.z_cut, + feed=_resolve(args.feed, tool, "feed_mm_min", 200.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + z_safe=args.z_safe, + z_cut=args.z_cut, return_home=args.return_home, workspace=args.workspace, - spindle_rpm=args.spindle_rpm, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), ) gcode = emit_gcode(args.ring, args.rotor, args.pen, opts) args.out.write_text(gcode) diff --git a/rollingmill/text_to_gcode.py b/rollingmill/text.py similarity index 66% rename from rollingmill/text_to_gcode.py rename to rollingmill/text.py index 32c91f8..0f5505f 100644 --- a/rollingmill/text_to_gcode.py +++ b/rollingmill/text.py @@ -9,6 +9,7 @@ Glyphs are emitted as pen-up / pen-down polyline runs intended to be streamed to the MDX-40A via the existing `rollingmill --file ` TUI flow. """ + from __future__ import annotations import argparse @@ -18,6 +19,9 @@ from pyhershey import glyph_factory +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + DEFAULT_FONT = "roman_simplex" @@ -38,7 +42,7 @@ class GCodeOpts: z_cut: float = -0.1 return_home: bool = False workspace: str | None = None # e.g. "G54".."G59"; emitted in header if set - spindle_rpm: int = 10000 # S word value, emitted with M03 + spindle_rpm: int = 10000 # S word value, emitted with M03 letter_spacing_mm: float = 0.0 # additive gap after each glyph's advance @@ -145,36 +149,56 @@ def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--text", help="String to render (omit with --list-fonts)") p.add_argument("--out", type=Path, help="Output .gcode file path") - p.add_argument("--font", default=DEFAULT_FONT, choices=fonts, - metavar="NAME", - help=f"Hershey font name (default: {DEFAULT_FONT}). " - f"Use --list-fonts to see all {len(fonts)} options.") - p.add_argument("--list-fonts", action="store_true", - help="Print available font names and exit") - p.add_argument("--height", type=float, default=10.0, - help="Cap height in mm (default 10.0)") - p.add_argument("--x0", type=float, default=0.0, - help="Start X in mm (default 0.0)") - p.add_argument("--y0", type=float, default=0.0, - help="Start Y in mm (default 0.0)") - p.add_argument("--feed", type=float, default=200.0, - help="Cutting feed mm/min (default 200)") - p.add_argument("--plunge-feed", type=float, default=50.0, - help="Z plunge feed mm/min (default 50)") - p.add_argument("--z-safe", type=float, default=2.0, - help="Pen-up Z height mm (default 2.0)") - p.add_argument("--z-cut", type=float, default=-0.1, - help="Pen-down Z depth mm (default -0.1)") - p.add_argument("--return-home", action="store_true", - help="Append G0 X0 Y0 at end of program") - p.add_argument("--workspace", default=None, - choices=["G54", "G55", "G56", "G57", "G58", "G59"], - help="Workspace offset to select in header (e.g. G54)") - p.add_argument("--spindle-rpm", type=int, default=10000, - help="Spindle speed in RPM, emitted with M03 (default 10000)") - p.add_argument("--letter-spacing", type=float, default=0.0, - help="Extra mm of gap after each glyph's natural advance " - "(default 0.0). Negative values tighten spacing.") + p.add_argument( + "--font", + default=DEFAULT_FONT, + choices=fonts, + metavar="NAME", + help=f"Hershey font name (default: {DEFAULT_FONT}). " + f"Use --list-fonts to see all {len(fonts)} options.", + ) + p.add_argument("--list-fonts", action="store_true", help="Print available font names and exit") + p.add_argument("--height", type=float, default=10.0, help="Cap height in mm (default 10.0)") + p.add_argument("--x0", type=float, default=0.0, help="Start X in mm (default 0.0)") + p.add_argument("--y0", type=float, default=0.0, help="Start Y in mm (default 0.0)") + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 200, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument("--z-safe", type=float, default=2.0, help="Pen-up Z height mm (default 2.0)") + p.add_argument("--z-cut", type=float, default=-0.1, help="Pen-down Z depth mm (default -0.1)") + p.add_argument("--return-home", action="store_true", help="Append G0 X0 Y0 at end of program") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select in header (e.g. G54)", + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed in RPM, emitted with M03 (default 10000, or from --tool)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --feed, --plunge-feed and " + "--spindle-rpm if those are not set", + ) + p.add_argument( + "--letter-spacing", + type=float, + default=0.0, + help="Extra mm of gap after each glyph's natural advance " + "(default 0.0). Negative values tighten spacing.", + ) args = p.parse_args(argv) if args.list_fonts: @@ -185,14 +209,24 @@ def main(argv: list[str] | None = None) -> int: if not args.text or not args.out: p.error("--text and --out are required (unless --list-fonts is given)") + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + opts = GCodeOpts( height_mm=args.height, - x0=args.x0, y0=args.y0, - feed=args.feed, plunge_feed=args.plunge_feed, - z_safe=args.z_safe, z_cut=args.z_cut, + x0=args.x0, + y0=args.y0, + feed=_resolve(args.feed, tool, "feed_mm_min", 200.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + z_safe=args.z_safe, + z_cut=args.z_cut, return_home=args.return_home, workspace=args.workspace, - spindle_rpm=args.spindle_rpm, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), letter_spacing_mm=args.letter_spacing, ) gcode = emit_gcode(args.text, args.font, opts) diff --git a/rollingmill/tools.py b/rollingmill/tools.py new file mode 100644 index 0000000..f4964d1 --- /dev/null +++ b/rollingmill/tools.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Host-side cutting-tool library, persisted as JSON at ~/.rollingmill/tools.json. + +Each tool carries the geometry needed by path planners (notably +``rollingmill.relief3D``) plus default feeds/speeds that emitters can use as +sensible defaults when the user does not pass explicit flags. + +The library is intentionally dependency-free — ``numpy`` is only imported +lazily by :meth:`Tool.footprint`, which path planners use to build a +morphological structuring element. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import math +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Literal + +ToolType = Literal["end_mill", "ball_nose", "v_bit", "engraver", "bull_nose"] + +VALID_TYPES: tuple[ToolType, ...] = ( + "end_mill", + "ball_nose", + "v_bit", + "engraver", + "bull_nose", +) + + +@dataclass +class Tool: + id: str + name: str + type: ToolType + diameter_mm: float + length_mm: float + flute_length_mm: float + shank_diameter_mm: float + flutes: int + feed_mm_min: float + plunge_feed_mm_min: float + spindle_rpm: int + half_angle_deg: float | None = None + corner_radius_mm: float | None = None + firmware_slot: int | None = None + max_doc_mm: float | None = None + + def __post_init__(self) -> None: + if self.type not in VALID_TYPES: + raise ValueError(f"tool {self.id!r}: type {self.type!r} must be one of {VALID_TYPES}") + if self.diameter_mm <= 0: + raise ValueError(f"tool {self.id!r}: diameter_mm must be > 0") + if self.length_mm <= 0: + raise ValueError(f"tool {self.id!r}: length_mm must be > 0") + if self.flute_length_mm <= 0: + raise ValueError(f"tool {self.id!r}: flute_length_mm must be > 0") + if self.shank_diameter_mm <= 0: + raise ValueError(f"tool {self.id!r}: shank_diameter_mm must be > 0") + if self.flutes <= 0: + raise ValueError(f"tool {self.id!r}: flutes must be > 0") + if self.feed_mm_min <= 0: + raise ValueError(f"tool {self.id!r}: feed_mm_min must be > 0") + if self.plunge_feed_mm_min <= 0: + raise ValueError(f"tool {self.id!r}: plunge_feed_mm_min must be > 0") + if self.spindle_rpm <= 0: + raise ValueError(f"tool {self.id!r}: spindle_rpm must be > 0") + if self.type == "v_bit" and self.half_angle_deg is None: + raise ValueError(f"tool {self.id!r}: v_bit requires half_angle_deg") + if self.type == "bull_nose" and self.corner_radius_mm is None: + raise ValueError(f"tool {self.id!r}: bull_nose requires corner_radius_mm") + if self.half_angle_deg is not None and not (0 < self.half_angle_deg < 90): + raise ValueError(f"tool {self.id!r}: half_angle_deg must be in (0, 90)") + if self.corner_radius_mm is not None and self.corner_radius_mm <= 0: + raise ValueError(f"tool {self.id!r}: corner_radius_mm must be > 0") + if self.firmware_slot is not None and not (1 <= self.firmware_slot <= 8): + raise ValueError(f"tool {self.id!r}: firmware_slot must be in 1..8") + + def to_dict(self) -> dict[str, Any]: + d = dataclasses.asdict(self) + return {k: v for k, v in d.items() if v is not None} + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "Tool": + known = {f.name for f in dataclasses.fields(cls)} + unknown = set(d) - known + if unknown: + raise ValueError(f"unknown tool fields: {sorted(unknown)}") + return cls(**d) + + def footprint(self, pitch_mm: float) -> Any: + """Return the tool tip's height-offset map as a 2D numpy array. + + Cells inside the cutter's reach hold the height the tip would touch + if the cutter axis passed through that offset; cells outside hold + ``+inf`` so that ``scipy.ndimage.grey_erosion`` treats them as + unreachable. + """ + import numpy as np # lazy; tools.py stays dependency-free + + if pitch_mm <= 0: + raise ValueError("pitch_mm must be > 0") + r = self.diameter_mm / 2.0 + radius_px = max(1, int(math.ceil(r / pitch_mm))) + n = 2 * radius_px + 1 + cy = cx = radius_px + yy, xx = np.mgrid[0:n, 0:n] + dx = (xx - cx) * pitch_mm + dy = (yy - cy) * pitch_mm + d = np.hypot(dx, dy) + inside = d <= r + 1e-9 + out = np.full((n, n), np.inf, dtype=float) + if self.type == "end_mill": + out[inside] = 0.0 + elif self.type == "ball_nose": + inner = inside + d_clipped = np.clip(d[inner], 0.0, r) + out[inner] = r - np.sqrt(np.maximum(r * r - d_clipped * d_clipped, 0.0)) + elif self.type == "v_bit" or self.type == "engraver": + assert self.half_angle_deg is not None + tan_t = math.tan(math.radians(self.half_angle_deg)) + out[inside] = d[inside] / tan_t + elif self.type == "bull_nose": + assert self.corner_radius_mm is not None + c = self.corner_radius_mm + flat_r = max(r - c, 0.0) + flat = d <= flat_r + 1e-9 + torus = inside & ~flat + out[flat] = 0.0 + d_in_torus = d[torus] - flat_r + d_in_torus = np.clip(d_in_torus, 0.0, c) + out[torus] = c - np.sqrt(np.maximum(c * c - d_in_torus * d_in_torus, 0.0)) + else: + raise ValueError(f"unhandled tool type {self.type!r}") + return out + + +def default_path() -> Path: + """Return the canonical tools.json path (honours $HOME).""" + return Path(os.path.expanduser("~")) / ".rollingmill" / "tools.json" + + +SEED_TOOLS: list[Tool] = [ + Tool( + id="end_6mm", + name="6mm 2-flute end mill", + type="end_mill", + diameter_mm=6.0, + length_mm=50.0, + flute_length_mm=22.0, + shank_diameter_mm=6.0, + flutes=2, + firmware_slot=1, + feed_mm_min=600.0, + plunge_feed_mm_min=150.0, + spindle_rpm=12000, + max_doc_mm=3.0, + ), + Tool( + id="ball_3mm", + name="3mm 2-flute ball nose", + type="ball_nose", + diameter_mm=3.0, + length_mm=38.0, + flute_length_mm=12.0, + shank_diameter_mm=3.175, + flutes=2, + firmware_slot=2, + feed_mm_min=400.0, + plunge_feed_mm_min=80.0, + spindle_rpm=14000, + max_doc_mm=1.0, + ), + Tool( + id="vbit_60", + name="60° V-bit", + type="v_bit", + diameter_mm=6.35, + length_mm=38.0, + flute_length_mm=10.0, + shank_diameter_mm=6.35, + flutes=2, + half_angle_deg=30.0, + firmware_slot=3, + feed_mm_min=500.0, + plunge_feed_mm_min=80.0, + spindle_rpm=14000, + max_doc_mm=1.0, + ), +] + + +def load(path: Path | None = None) -> list[Tool]: + """Load tools from ``path`` (defaulting to ``default_path()``). + + If the file does not exist, seed it with ``SEED_TOOLS`` and return that. + """ + p = path or default_path() + if not p.exists(): + p.parent.mkdir(parents=True, exist_ok=True) + save(SEED_TOOLS, p) + return list(SEED_TOOLS) + raw = json.loads(p.read_text()) + if not isinstance(raw, list): + raise ValueError(f"{p}: expected a JSON list of tools, got {type(raw).__name__}") + return [Tool.from_dict(d) for d in raw] + + +def save(tools: Iterable[Tool], path: Path | None = None) -> None: + p = path or default_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = [t.to_dict() for t in tools] + p.write_text(json.dumps(payload, indent=2) + "\n") + + +def resolve(explicit: Any, tool: "Tool | None", attr: str, fallback: Any) -> Any: + """Pick a value with priority: explicit CLI > tool default > fallback. + + Used by emitters wiring a ``--tool`` flag alongside legacy per-value flags: + if the user passed the legacy flag (``explicit is not None``) it wins; + otherwise the tool's attribute is used; otherwise the hard-coded fallback. + """ + if explicit is not None: + return explicit + if tool is not None: + v = getattr(tool, attr, None) + if v is not None: + return v + return fallback + + +def find(tools: list[Tool], ref: str) -> Tool: + """Find a tool by id, falling back to exact name match.""" + for t in tools: + if t.id == ref: + return t + for t in tools: + if t.name == ref: + return t + known = ", ".join(t.id for t in tools) or "(none)" + raise KeyError(f"no tool with id or name {ref!r}; known: {known}") + + +def add(tools: list[Tool], tool: Tool) -> list[Tool]: + if any(t.id == tool.id for t in tools): + raise ValueError(f"tool id {tool.id!r} already exists") + return tools + [tool] + + +def remove(tools: list[Tool], id: str) -> list[Tool]: + out = [t for t in tools if t.id != id] + if len(out) == len(tools): + raise KeyError(f"no tool with id {id!r}") + return out + + +def _print_table(tools: list[Tool]) -> None: + if not tools: + print("(no tools)") + return + header = f"{'id':<14} {'type':<10} {'dia':>6} {'len':>6} {'slot':>4} name" + print(header) + print("-" * len(header)) + for t in tools: + slot = "-" if t.firmware_slot is None else str(t.firmware_slot) + print( + f"{t.id:<14} {t.type:<10} {t.diameter_mm:>6.2f} {t.length_mm:>6.1f} {slot:>4} {t.name}" + ) + + +def _print_detail(t: Tool) -> None: + for k, v in t.to_dict().items(): + print(f"{k}: {v}") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument( + "--path", + type=Path, + default=None, + help="Override library path (default: ~/.rollingmill/tools.json)", + ) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("init", help="Create the tools file with seed tools") + sub.add_parser("list", help="List all tools") + + sp_show = sub.add_parser("show", help="Show one tool by id or name") + sp_show.add_argument("ref") + + sp_rm = sub.add_parser("remove", help="Remove a tool by id") + sp_rm.add_argument("id") + + args = p.parse_args(argv) + path = args.path + + if args.cmd == "init": + target = path or default_path() + if target.exists(): + print(f"{target} already exists; not overwriting") + return 1 + save(SEED_TOOLS, target) + print(f"wrote {target} with {len(SEED_TOOLS)} seed tools") + return 0 + + if args.cmd == "list": + _print_table(load(path)) + return 0 + + if args.cmd == "show": + _print_detail(find(load(path), args.ref)) + return 0 + + if args.cmd == "remove": + tools = remove(load(path), args.id) + save(tools, path) + print(f"removed {args.id}; {len(tools)} tools remain") + return 0 + + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollingmill/totem.py b/rollingmill/totem.py new file mode 100644 index 0000000..74f9555 --- /dev/null +++ b/rollingmill/totem.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 +"""Render a stack of turned-spindle shapes to MDX-40A rotary-axis g-code. + +Designed for an MDX-40A fitted with a rotary attachment such that the +workpiece extends along the **X** axis and the **A** axis rotates around X +(the standard Roland rotary mount). Each section of the totem is a +cross-section shape stacked along X; the sum of section lengths is the +total spindle length. + +Coordinate system +----------------- +- X is the longitudinal totem axis. Sections stack from ``opts.x0`` in the + +X direction. +- Z is measured from the workpiece rotation axis: ``Z=0`` is on the axis, + ``Z>0`` is above. The original cylindrical stock has its surface at + ``Z = stock_radius`` and cuts descend toward the axis. +- ``Z = safe_radius`` is the absolute clearance height above the workpiece + at which the tool can rotate the workpiece safely (i.e. ``safe_radius`` + must be ``>= stock_radius`` with a margin). +- A is in degrees. Cuts at one face / one angular slice keep the workpiece + stationary; the workpiece is rotated only when the tool is at the safe Z. +- Y stays at 0 (on-axis) for round shapes; polygon facet clearing rasters + Y across the face width (which is symmetric around Y=0 since the face + is currently up). + +Cut algorithm per section +------------------------- +**Round shapes** (``cylinder``, ``ellipse``, ``donut``): step A in fine +increments. At each angle theta, descend Z in passes from the stock +surface down to ``r(theta)`` (the radius at that angle for this shape), +sweeping X at each Z. Single tool path at Y=0. + +**Polygon shapes** (``triangle``, ``square``, ``pentagon``, ``hex``, +``octagon``): step A in ``n`` discrete positions (one per face). At each +face position, raster the face rectangle (length = section length in X, +width = polygon side length in Y) at descending Z until Z reaches the +polygon's apothem (face center distance from axis). Z stepover uses +``depth_step``; Y stepover uses ``tool_diameter * step_over_ratio``. + +The "size" parameter for each shape is the **circumscribed / maximum +radius**: the distance from the rotation axis to the polygon's corner, +or to the cylinder/ellipse outer radius. So a square with ``size=12`` has +corners at radius 12, faces at radius ``12*cos(pi/4) = 8.49``, and side +length ``12*sqrt(2) = 16.97`` mm. + +Safety: every A-move is preceded by a Z retract to ``safe_radius`` and a +return to Y=0, ensuring the tool clears the (potentially partly-cut) +workpiece during rotation. +""" + +from __future__ import annotations + +import argparse +import math +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + + +POLYGON_SIDES: dict[str, int] = { + "triangle": 3, + "square": 4, + "pentagon": 5, + "hex": 6, + "octagon": 8, +} +ROUND_SHAPES: set[str] = {"cylinder", "ellipse", "donut"} +EMPTY_SHAPES: set[str] = {"empty"} +SHAPE_ALIASES: dict[str, str] = { + "cube": "square", # user wrote "cube" in example + "torus": "donut", + "hexagon": "hex", +} +ALL_SHAPES: set[str] = set(POLYGON_SIDES) | ROUND_SHAPES | EMPTY_SHAPES + + +@dataclass +class Section: + shape: str + size: float # max / circumscribed radius in mm + length: float # extent along X (totem axis) in mm + x_start: float = 0.0 # set by emit_gcode based on cumulative section sums + + @property + def x_end(self) -> float: + return self.x_start + self.length + + +@dataclass +class TotemOpts: + out: Path | None = None + stock_radius: float = 15.0 + safe_radius: float = 20.0 # absolute Z height clearance for A-moves + depth_step: float = 0.5 # max Z descent per pass + angle_step_deg: float = 5.0 # A step for round shapes + tool_diameter: float = 3.0 # for polygon raster step-over + step_over_ratio: float = 0.5 + ellipse_aspect: float = 0.6 # semi-minor / semi-major for "ellipse" + x0: float = 0.0 + feed: float = 200.0 + plunge_feed: float = 50.0 + a_feed: float = 1800.0 # deg/min for A-axis motion + spindle_rpm: int = 10000 + workspace: str | None = None # G54..G59 + return_home: bool = False + + +def _fmt(v: float) -> str: + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s if s not in ("", "-0") else "0" + + +def _fmtp(v: float) -> str: + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0") + if s.endswith("."): + s += "0" + if s == "-0.0": + s = "0.0" + return s + + +# ---------- shape radii ---------- + + +def _r_cylinder(size: float, theta: float) -> float: + return size + + +def _r_ellipse(size: float, theta: float, aspect: float) -> float: + a = size + b = size * aspect + return (a * b) / math.hypot(b * math.cos(theta), a * math.sin(theta)) + + +def _r_donut(size: float, theta: float, x_rel: float, length: float) -> float: + """Bead/torus profile: r varies along X as a half-sine bulge from + ``size/2`` at the ends to ``size`` at the centre. Theta-independent.""" + base = size * 0.5 + bulge = size - base + return base + bulge * math.sin(math.pi * x_rel / length) + + +def _r_polygon(n: int, size: float, theta: float) -> float: + """Radius from polygon centre to its boundary at angle theta, for a + regular n-gon with circumscribed radius ``size``.""" + face_angle = 2.0 * math.pi / n + local = ((theta + face_angle / 2.0) % face_angle) - face_angle / 2.0 + apothem = size * math.cos(math.pi / n) + return apothem / math.cos(local) + + +# ---------- g-code emission helpers ---------- + + +def _z_passes(opts: TotemOpts, z_final: float) -> list[float]: + """List of Z depths from just below the stock surface down to z_final.""" + if z_final >= opts.stock_radius: + return [] # no material to remove + n_passes = max(1, math.ceil((opts.stock_radius - z_final) / opts.depth_step)) + return [ + opts.stock_radius - (i + 1) * (opts.stock_radius - z_final) / n_passes + for i in range(n_passes) + ] + + +def _retract_and_rotate(opts: TotemOpts, a_deg: float) -> list[str]: + """Emit the safe retract + A-axis rotation sequence. + + With A rotating around X, the tool must be centred on the workpiece + axis in the Y direction (Y=0) and lifted in Z above the workpiece + before any A move. X is along the totem and need not change. + """ + return [ + f"G00 Z{_fmtp(opts.safe_radius)}", + f"G00 Y0.0", + f"G01 A{_fmtp(a_deg)} F{_fmtp(opts.a_feed)}", + ] + + +# ---------- per-shape emit ---------- + + +def _emit_round_section(opts: TotemOpts, section: Section) -> list[str]: + """Round shape: step A finely, sweep X back-and-forth across Z passes. + + The tool stays at Y=0 (on the rotation axis when projected to the YZ + plane through the workpiece) and sweeps X between x_start and x_end + along the totem length. Within one A angle, consecutive Z passes + alternate direction and plunge in place at the previous sweep's end + — the retract to safe_radius only happens once the innermost depth + is reached, before the next A rotation.""" + out: list[str] = [] + + n_steps = max(1, int(round(360.0 / opts.angle_step_deg))) + is_donut = section.shape == "donut" + x_start_abs = opts.x0 + section.x_start + x_end_abs = opts.x0 + section.x_end + + if is_donut: + # Switch to the XZ arc plane for the donut bulge arcs (G18). The + # rest of the program assumes G17, so we restore it after the + # section is emitted. + out.append("G18") + + for i in range(n_steps): + theta_deg = i * (360.0 / n_steps) + theta = math.radians(theta_deg) + + out.extend(_retract_and_rotate(opts, theta_deg)) + + # For donut, r varies along X. Each pass at depth z_pass is cut as + # G02/G03 arcs in the XZ plane: an optional G01 flat lead at z_pass + # up to where the half-sine bulge crosses z_pass (x1), a rising arc + # to the peak (length/2, size), a falling arc to the other crossing + # (x2, z_pass), and an optional flat lead-out. Many small polyline + # segments overrun the Roland's block-processing rate and cause + # jerky motion; one rising + one falling arc per pass keeps the + # firmware fed. + if is_donut: + base = section.size * 0.5 + bulge = section.size - base + z_passes = _z_passes(opts, base) + if not z_passes: + continue + peak = section.size + x_mid_abs = x_start_abs + section.length / 2 + out.append(f"G00 X{_fmtp(x_start_abs)} Y0.0") + out.append(f"G01 Z{_fmtp(opts.stock_radius)} F{_fmtp(opts.plunge_feed)}") + sweep_dir = 1 # +1 = left-to-right, -1 = right-to-left + for z_pass in z_passes: + out.append(f"G01 Z{_fmtp(z_pass)} F{_fmtp(opts.plunge_feed)}") + out.append(f"F{_fmtp(opts.feed)}") + if z_pass >= peak - 1e-9: + # Bulge peak is at or below this pass: cut a flat all + # the way across. + target_x = x_end_abs if sweep_dir > 0 else x_start_abs + out.append(f"G01 X{_fmtp(target_x)}") + else: + # Bulge crosses z_pass at x1 (rising) and x2 (falling). + ratio = max(0.0, min(1.0, (z_pass - base) / bulge)) + x1 = (section.length / math.pi) * math.asin(ratio) + x2 = section.length - x1 + dx = section.length / 2 - x1 + # The arc through (x1, z_pass), (length/2, peak), + # (x2, z_pass) has its centre at (length/2, zc) by + # symmetry. + zc = ((peak * peak - z_pass * z_pass) - dx * dx) / (2.0 * (peak - z_pass)) + if sweep_dir > 0: + if x1 > 1e-6: + out.append(f"G01 X{_fmtp(x_start_abs + x1)}") + out.append( + f"G03 X{_fmtp(x_mid_abs)} Z{_fmtp(peak)} " + f"I{_fmtp(dx)} K{_fmtp(zc - z_pass)}" + ) + out.append( + f"G03 X{_fmtp(x_start_abs + x2)} " + f"Z{_fmtp(z_pass)} " + f"I0 K{_fmtp(zc - peak)}" + ) + if x2 < section.length - 1e-6: + out.append(f"G01 X{_fmtp(x_end_abs)}") + else: + if x2 < section.length - 1e-6: + out.append(f"G01 X{_fmtp(x_start_abs + x2)}") + out.append( + f"G02 X{_fmtp(x_mid_abs)} Z{_fmtp(peak)} " + f"I{_fmtp(-dx)} K{_fmtp(zc - z_pass)}" + ) + out.append( + f"G02 X{_fmtp(x_start_abs + x1)} " + f"Z{_fmtp(z_pass)} " + f"I0 K{_fmtp(zc - peak)}" + ) + if x1 > 1e-6: + out.append(f"G01 X{_fmtp(x_start_abs)}") + sweep_dir *= -1 + continue + + if section.shape == "cylinder": + r_target = _r_cylinder(section.size, theta) + elif section.shape == "ellipse": + r_target = _r_ellipse(section.size, theta, opts.ellipse_aspect) + else: + raise ValueError(f"unknown round shape: {section.shape!r}") + + z_passes = _z_passes(opts, r_target) + if not z_passes: + continue + out.append(f"G00 X{_fmtp(x_start_abs)} Y0.0") + sweep_dir = 1 + for z_pass in z_passes: + out.append(f"G01 Z{_fmtp(z_pass)} F{_fmtp(opts.plunge_feed)}") + out.append(f"F{_fmtp(opts.feed)}") + target_x = x_end_abs if sweep_dir > 0 else x_start_abs + out.append(f"G01 X{_fmtp(target_x)}") + sweep_dir *= -1 + + if is_donut: + out.append("G17") # restore XY arc plane + + return out + + +def _emit_empty_section(opts: TotemOpts, section: Section) -> list[str]: + """No-cut spacer: leaves the stock untouched for this section's length. + + The X stacking cursor is advanced by ``section.length`` in + :func:`emit_gcode` regardless of what this returns, so the next + section's cuts begin further down the workpiece.""" + return [] + + +def _emit_polygon_section(opts: TotemOpts, section: Section) -> list[str]: + """Polygon: discrete facets; raster X/Y plane at descending Z per face. + + With the totem along X, each face rectangle when the face is "up" spans + X in [x_start, x_end] (along the workpiece length) and Y in + [-half_side, +half_side] (the face width perpendicular to the rotation + axis). The raster does long sweeps along X with Y stepping between + sweeps. Every tool-tip position is inset from the face boundary by the + tool radius so the body just touches the boundary without overcutting. + After each Z pass's serpentine raster, the tool traces the inset + rectangle perimeter as a cleanup pass: a cylindrical end-mill leaves + small arrow-shaped uncut cusps at every raster direction change, and a + closed perimeter pass at the same Z removes them before descending.""" + out: list[str] = [] + n = POLYGON_SIDES[section.shape] + apothem = section.size * math.cos(math.pi / n) + side = 2.0 * section.size * math.sin(math.pi / n) + half_side = side / 2.0 + tool_r = opts.tool_diameter / 2.0 + + # Inset rectangle for the tool TIP, in the rotated face frame: + # X (totem length) ranges over the section's extent. + # Y (face width) is symmetric around the rotation axis. + x_min = section.x_start + tool_r + x_max = section.x_end - tool_r + y_min = -half_side + tool_r + y_max = +half_side - tool_r + if x_max - x_min < 1e-6 or y_max - y_min < 1e-6: + raise ValueError( + f"polygon {section.shape!r} face too small for the tool " + f"(side={side:.3f} mm, length={section.length:.3f} mm, " + f"tool diameter={opts.tool_diameter:.3f} mm)" + ) + + step_over = max(0.1, opts.tool_diameter * opts.step_over_ratio) + + for face_idx in range(n): + face_angle_deg = face_idx * (360.0 / n) + out.extend(_retract_and_rotate(opts, face_angle_deg)) + + # Corner-clearing extensions in the face-width (Y) direction. + # Adjacent polygon faces share a corner along Y=±half_side. To + # avoid leaving an unsupported wedge of stock at that corner when + # the second of the pair is cut, each face overhangs the inset + # rectangle toward the next-to-be-cut face. With right-hand-rule + # positive A around +X (CW from the operator's view at the + # tailstock side), the next-to-be-cut face sits at +Y in the tool + # frame at the current A position: + # * Face 0: extend BOTH sides (+Y pre-clears C_{0,1}; -Y + # pre-clears C_{N-1,0}). + # * Faces 1..N-2: extend +Y only (toward face_{i+1}; the -Y + # side, C_{i-1,i}, was already pre-cleared by face_{i-1}'s + # +Y extension). + # * Face N-1: extend BOTH sides too. Geometrically face 0's + # -Y body and face N-2's +Y body each land *tangent* to the + # original cylinder boundary at the corner C_{N-1,0} / + # C_{N-2,N-1} (chord_half - tool_r places the tool body + # exactly on the cylinder). A tangent pass has no chip load + # — it brushes the surface without biting in, leaving a + # thin residual ridge. Face N-1 with no extension also + # terminates its body tangent at the same line, from the + # same side, so no cutter ever passes the corner with + # engagement. Extending face N-1 makes it approach each + # corner from the opposite frame, severing the burr. + # The extension distance is computed per Z pass below: at height + # z, the cylindrical stock's half-chord in Y is sqrt(R^2 - z^2), + # so the tool tip only needs to reach (chord_half - tool_r) for + # the body to sweep past the stock edge at that Z. Shallower Z + # passes (near the stock surface) need little or no extension; + # the deepest pass at Z=apothem needs the most. + extend_pos = True + extend_neg = face_idx == 0 or face_idx == n - 1 + + for z_pass in _z_passes(opts, apothem): + chord_half = math.sqrt(max(0.0, opts.stock_radius**2 - z_pass**2)) + y_clear = chord_half - tool_r + y_max_face = max(y_max, y_clear) if extend_pos else y_max + y_min_face = min(y_min, -y_clear) if extend_neg else y_min + + n_y_steps = max(1, math.ceil((y_max_face - y_min_face) / step_over)) + y_positions = [ + y_min_face + (i / n_y_steps) * (y_max_face - y_min_face) + for i in range(n_y_steps + 1) + ] + + # Start of pass: rapid to first Y, plunge to z, serpentine raster + # along X (long axis). + out.append(f"G00 X{_fmtp(opts.x0 + x_min)} Y{_fmtp(y_positions[0])}") + out.append(f"G01 Z{_fmtp(z_pass)} F{_fmtp(opts.plunge_feed)}") + out.append(f"F{_fmtp(opts.feed)}") + sweep_dir = 1 # +1 = toward x_max, -1 = toward x_min + for yi, y in enumerate(y_positions): + if yi > 0: + out.append(f"G01 Y{_fmtp(y)}") + target_x = x_max if sweep_dir > 0 else x_min + out.append(f"G01 X{_fmtp(opts.x0 + target_x)}") + sweep_dir *= -1 + + # Cleanup perimeter trace at the same Z, removing the arrow- + # shaped cusps that a cylindrical tool leaves at each raster + # reversal. Walk from the raster's end to the (x_min, y_min) + # corner through the already-cleared region, then close the + # rectangle. + out.append(f"G01 X{_fmtp(opts.x0 + x_min)} Y{_fmtp(y_min)}") + out.append(f"G01 X{_fmtp(opts.x0 + x_max)}") + out.append(f"G01 Y{_fmtp(y_max)}") + out.append(f"G01 X{_fmtp(opts.x0 + x_min)}") + out.append(f"G01 Y{_fmtp(y_min)}") + # No safe-Z retract here: the next Z pass at this same A + # angle plunges straight from this Z to a deeper one, and + # the next face's _retract_and_rotate retracts before the + # A move. End-of-program emits a final safe-Z retract too. + return out + + +# ---------- public API ---------- + + +def _validate_sections(opts: TotemOpts, sections: list[Section]) -> None: + if not sections: + raise ValueError("at least one --shape section is required") + if opts.stock_radius <= 0: + raise ValueError("stock_radius must be positive") + if opts.safe_radius < opts.stock_radius: + raise ValueError( + f"safe_radius ({opts.safe_radius}) must be >= stock_radius ({opts.stock_radius})" + ) + for s in sections: + canonical = SHAPE_ALIASES.get(s.shape, s.shape) + if canonical not in ALL_SHAPES: + valid = ", ".join(sorted(ALL_SHAPES | set(SHAPE_ALIASES))) + raise ValueError(f"unknown shape {s.shape!r}; valid: {valid}") + s.shape = canonical + if canonical not in EMPTY_SHAPES: + if s.size <= 0: + raise ValueError(f"shape size must be positive, got {s.size}") + if s.size > opts.stock_radius: + raise ValueError( + f"shape {s.shape!r} size {s.size} exceeds stock_radius {opts.stock_radius}" + ) + if s.length <= 0: + raise ValueError(f"shape length must be positive, got {s.length}") + + +def emit_gcode(sections: list[Section], opts: TotemOpts) -> str: + """Render a stacked-shape totem as MDX-40A rotary-axis g-code.""" + _validate_sections(opts, sections) + + # Compute cumulative x_start for each section. + cursor = 0.0 + for s in sections: + s.x_start = cursor + cursor += s.length + total_length = cursor + + out: list[str] = [] + w = out.append + + w("(Generated by rollingmill.totem -- rotary-axis spindle)") + w(f"(Stock radius: {_fmt(opts.stock_radius)} mm, total length: {_fmt(total_length)} mm)") + for s in sections: + w( + f"(Section: {s.shape} size={_fmt(s.size)} length={_fmt(s.length)} " + f"@ X={_fmt(opts.x0 + s.x_start)})" + ) + w("%") + w("O00000001") + w("G21") # mm + w("G90") # absolute + w("G17") # XY plane (arc plane; irrelevant here but conventional) + if opts.workspace: + w(opts.workspace) + w(f"F{_fmtp(opts.feed)}") + w(f"S{opts.spindle_rpm}") + w("M03") # spindle on (CW) + w(f"G00 Z{_fmtp(opts.safe_radius)}") + w(f"G01 A0.0 F{_fmtp(opts.a_feed)}") # zero A datum + + for section in sections: + w( + f"(--- section: {section.shape} x=[{_fmt(opts.x0 + section.x_start)}, " + f"{_fmt(opts.x0 + section.x_end)}] ---)" + ) + if section.shape in POLYGON_SIDES: + out.extend(_emit_polygon_section(opts, section)) + elif section.shape in ROUND_SHAPES: + out.extend(_emit_round_section(opts, section)) + elif section.shape in EMPTY_SHAPES: + out.extend(_emit_empty_section(opts, section)) + else: + raise AssertionError(f"unreachable shape: {section.shape}") + + w(f"G00 Z{_fmtp(opts.safe_radius)}") + w("G00 Y0.0") + if opts.return_home: + w("G00 X0.0 Y0.0") + w("G01 A0.0 F" + _fmtp(opts.a_feed)) + w("M05") + w("M30") + return "\n".join(out) + "\n" + + +# ---------- CLI ---------- + + +class _ShapeAction(argparse.Action): + """Append a Section parsed from NAME:SIZE:LENGTH (or ; or , separators).""" + + def __call__(self, parser, namespace, values, option_string=None): + sections = getattr(namespace, self.dest, None) or [] + spec = str(values).replace(";", ":").replace(",", ":") + parts = [p for p in spec.split(":") if p] + name = parts[0].lower() if parts else "" + # "empty" is a no-cut spacer with only a length, no size. + if name in EMPTY_SHAPES: + if len(parts) != 2: + raise argparse.ArgumentError( + self, + f"expected {name}:LENGTH (got {values!r}); e.g. {name}:40", + ) + try: + length = float(parts[1]) + except ValueError as e: + raise argparse.ArgumentError(self, f"length must be numeric: {e}") + sections.append(Section(shape=name, size=0.0, length=length)) + setattr(namespace, self.dest, sections) + return + if len(parts) != 3: + raise argparse.ArgumentError( + self, + f"expected NAME:SIZE:LENGTH (got {values!r}); e.g. cylinder:10:10", + ) + try: + size = float(parts[1]) + length = float(parts[2]) + except ValueError as e: + raise argparse.ArgumentError(self, f"size/length must be numeric: {e}") + sections.append(Section(shape=name, size=size, length=length)) + setattr(namespace, self.dest, sections) + + +def main(argv: list[str] | None = None) -> int: + valid = sorted(ALL_SHAPES) + sorted(SHAPE_ALIASES) + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument( + "--shape", + action=_ShapeAction, + dest="sections", + default=[], + metavar="NAME:SIZE:LENGTH", + help=( + "Stack a section on the totem; repeatable. NAME one of: " + + ", ".join(valid) + + ". SIZE = max/circumscribed radius (mm). " + "LENGTH = extent along X (mm). Order on the command line is " + "the stacking order, starting at X=x0." + ), + ) + p.add_argument("--out", type=Path, required=True, help="Output .gcode file path") + p.add_argument( + "--workspace", + default=None, + choices=["G54", "G55", "G56", "G57", "G58", "G59"], + help="Workspace offset to select (origin of the stock)", + ) + p.add_argument( + "--stock-radius", + type=float, + default=15.0, + help="Initial cylindrical stock radius mm (default 15)", + ) + p.add_argument( + "--safe-radius", + type=float, + default=20.0, + help="Absolute Z clearance for A-moves mm (default 20). Must be >= stock_radius.", + ) + p.add_argument( + "--depth-step", type=float, default=0.5, help="Maximum Z descent per pass mm (default 0.5)" + ) + p.add_argument( + "--angle-step", + type=float, + default=5.0, + help="A-axis step for round shapes, deg (default 5)", + ) + p.add_argument( + "--tool", + default=None, + help="Tool id or name from ~/.rollingmill/tools.json; " + "supplies defaults for --tool-diameter, --feed, " + "--plunge-feed and --spindle-rpm if those are not set", + ) + p.add_argument( + "--tool-diameter", + type=float, + default=None, + help="Tool diameter mm, for polygon raster step-over (default 3.0, or from --tool)", + ) + p.add_argument( + "--step-over", + type=float, + default=0.5, + help="Y step-over as fraction of tool diameter (default 0.5 = 50%% overlap)", + ) + p.add_argument( + "--ellipse-aspect", + type=float, + default=0.6, + help="Semi-minor / semi-major for ellipse (default 0.6)", + ) + p.add_argument( + "--x0", type=float, default=0.0, help="X offset for the base of the totem mm (default 0)" + ) + p.add_argument( + "--feed", type=float, default=None, help="Cutting feed mm/min (default 200, or from --tool)" + ) + p.add_argument( + "--plunge-feed", + type=float, + default=None, + help="Z plunge feed mm/min (default 50, or from --tool)", + ) + p.add_argument( + "--a-feed", type=float, default=1800.0, help="A axis feed deg/min (default 1800)" + ) + p.add_argument( + "--spindle-rpm", + type=int, + default=None, + help="Spindle speed RPM (default 10000, or from --tool)", + ) + p.add_argument( + "--return-home", + action="store_true", + help="Append rapid to X0 Y0 A0 at the end of the program", + ) + args = p.parse_args(argv) + + if not args.sections: + p.error("at least one --shape is required (e.g. --shape cylinder:10:20)") + + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + + opts = TotemOpts( + out=args.out, + stock_radius=args.stock_radius, + safe_radius=args.safe_radius, + depth_step=args.depth_step, + angle_step_deg=args.angle_step, + tool_diameter=_resolve(args.tool_diameter, tool, "diameter_mm", 3.0), + step_over_ratio=args.step_over, + ellipse_aspect=args.ellipse_aspect, + x0=args.x0, + feed=_resolve(args.feed, tool, "feed_mm_min", 200.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 50.0), + a_feed=args.a_feed, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 10000), + workspace=args.workspace, + return_home=args.return_home, + ) + _print_summary(opts, args.sections) + try: + gcode = emit_gcode(args.sections, opts) + except ValueError as e: + p.error(str(e)) + args.out.write_text(gcode) + print(f"wrote {args.out} ({len(gcode.splitlines())} lines)") + return 0 + + +def _print_summary(opts: TotemOpts, sections: list[Section]) -> None: + """Print the resolved TotemOpts and the stacked sections table.""" + print("TotemOpts:") + rows = [ + ("stock_radius", f"{opts.stock_radius:.3f} mm"), + ("safe_radius", f"{opts.safe_radius:.3f} mm"), + ("depth_step", f"{opts.depth_step:.3f} mm"), + ("angle_step", f"{opts.angle_step_deg:.3f} deg"), + ("tool_diameter", f"{opts.tool_diameter:.3f} mm"), + ("step_over", f"{opts.step_over_ratio:.3f} (frac of tool dia)"), + ("ellipse_aspect", f"{opts.ellipse_aspect:.3f}"), + ("x0", f"{opts.x0:.3f} mm"), + ("feed", f"{opts.feed:.1f} mm/min"), + ("plunge_feed", f"{opts.plunge_feed:.1f} mm/min"), + ("a_feed", f"{opts.a_feed:.1f} deg/min"), + ("spindle_rpm", f"{opts.spindle_rpm}"), + ("workspace", opts.workspace or "(none)"), + ("return_home", "yes" if opts.return_home else "no"), + ] + key_w = max(len(k) for k, _ in rows) + for k, v in rows: + print(f" {k:<{key_w}} {v}") + + print() + print("Sections:") + header = ("#", "shape", "size", "length", "x_start", "x_end") + cursor = opts.x0 + rows2: list[tuple[str, ...]] = [] + for i, s in enumerate(sections, start=1): + canonical = SHAPE_ALIASES.get(s.shape, s.shape) + x_start = cursor + x_end = cursor + s.length + cursor = x_end + rows2.append( + ( + str(i), + canonical + (f" (={s.shape})" if canonical != s.shape else ""), + f"{s.size:.3f}", + f"{s.length:.3f}", + f"{x_start:.3f}", + f"{x_end:.3f}", + ) + ) + widths = [max(len(r[c]) for r in (header, *rows2)) for c in range(len(header))] + fmt = " " + " ".join(f"{{:<{w}}}" for w in widths) + print(fmt.format(*header)) + print(fmt.format(*("-" * w for w in widths))) + for row in rows2: + print(fmt.format(*row)) + print(f" total length: {cursor - opts.x0:.3f} mm") + print() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollingmill/vcarve.py b/rollingmill/vcarve.py new file mode 100644 index 0000000..b59c1d3 --- /dev/null +++ b/rollingmill/vcarve.py @@ -0,0 +1,1686 @@ +#!/usr/bin/env python3 +"""Convert an SVG drawing into V-carving g-code for the MDX-40A. + +This is a *general-purpose* V-carve engine that treats an SVG the way you'd +want a sign carved: it classifies every shape as either a **filled region** or +a **stroke-only path** and handles each correctly. + + * **Filled regions** (e.g. bold letterforms) are carved along their **medial + axis**, computed directly from the vector outline as the interior of the + boundary's Voronoi diagram (no rasterisation), plunging deeper where the + shape is wider so the V-bit's rim just touches the edges -- tapering to + sharp points at terminals. This is robust where a raster skeletoniser + collapses clean convex glyphs (a diamond) or drops tiny dots (a colon). + * **Stroke-only paths** (a logo outline, an arrow) are carved along their + **vector centreline** at a *constant* depth fixed by the declared stroke + width. + +Both share one depth rule set by the bit's half-angle ``t``. At depth ``d`` the +cone has radius ``r = d * tan(t)``, so to make the rim touch edges a clearance +``r`` away we plunge to ``d = r / tan(t)``. For a filled region ``r`` is the +local medial clearance; for a stroke of width ``w`` it is ``w / 2`` (constant). + +Everything works from the SVG vector geometry via svgelements -- convert text +to paths in the editor first. The heavy dependencies (numpy, scipy, pillow, +svgelements, plus scikit-image for the ``--png`` raster fallback) are optional +and imported lazily, matching :mod:`rollingmill.relief3D`. + +Output is single-tool g-code streamable via ``rollingmill --file out.gcode``. +""" + +from __future__ import annotations + +import argparse +import math +import sys +import time +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path + +from rollingmill import tools as _tools +from rollingmill.tools import resolve as _resolve + +_VCARVE_EXTRA_HINT = ( + "rollingmill.vcarve needs numpy, scipy, scikit-image, pillow and svgelements.\n" + "Install with:\n" + " pip install rollingmill[vcarve]\n" + "or manually:\n" + " pip install numpy scipy scikit-image pillow svgelements" +) + + +def _progress(opts: VCarveOpts, msg: str) -> None: + """Print a progress line to stdout when running as a CLI (opts.progress).""" + if opts.progress: + print(f"[vcarve] {msg}", flush=True) + + +def _require_deps(): + try: + import numpy as np + from PIL import Image + from skimage.morphology import medial_axis + except ImportError as e: + raise SystemExit(f"{e}\n\n{_VCARVE_EXTRA_HINT}") from e + return np, Image, medial_axis + + +def _require_svg(): + try: + import svgelements + except ImportError as e: + raise SystemExit(f"{e}\n\n{_VCARVE_EXTRA_HINT}") from e + return svgelements + + +# --- number formatting (kept consistent with rollingmill.spiral) ------------- + + +def _fmt(v: float) -> str: + """Format a number with up to 3 decimal places, trimmed.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s if s not in ("", "-0") else "0" + + +def _fmtp(v: float) -> str: + """Format a position value: up to 3 decimal places, always at least 1.""" + s = f"{v:.3f}" + if "." in s: + s = s.rstrip("0") + if s.endswith("."): + s += "0" + if s == "-0.0": + s = "0.0" + return s + + +@dataclass +class VCarveOpts: + page_w_mm: float = 300.0 + page_h_mm: float = 300.0 + pitch_mm: float = 0.05 # raster resolution (mm/pixel) for the filled-region pass + half_angle_deg: float = 30.0 # V-bit half-angle from axis (60deg incl -> 30) + threshold: int = 200 # luminance < threshold counts as ink + max_depth_mm: float = 3.0 # clamp the deepest cut (material/bit limit) + min_depth_mm: float = 0.0 # raise faint grooves to at least this depth + stepdown_mm: float = 0.6 # max depth removed per pass (roughing) + prune_mm: float = 0.4 # drop skeleton spur branches shorter than this + chord_tol_mm: float = 0.05 # toolpath decimation tolerance + x0: float = 0.0 + y0: float = 0.0 + feed: float = 400.0 + plunge_feed: float = 80.0 + z_safe: float = 2.0 + spindle_rpm: int = 14000 + return_home: bool = False + workspace: str | None = None + stroke_flatten_mm: float = 0.3 # curve sampling step for stroke centrelines + markers: bool = True # expand marker-start/-end (arrowheads) into grooves + arcs: bool = True # fit circular stroke runs to G02/G03 arcs + arc_tol_mm: float = 0.02 # max deviation when fitting an arc to a stroke run + fill_arcs: bool = False # opt-in: fit circular fill medial-axis runs to arcs (helix in Z) + fill_arc_tol_mm: float = 0.06 # arc-fit tolerance for the (coarser) fill medial axis + medial_step_mm: float = 0.15 # boundary sampling step for the vector (Voronoi) fill medial + medial_hair_adj: int = 2 # prune Voronoi edges between boundary samples this close on a ring + preview_path: Path | None = None # if set, also write a toolpath preview PNG + preview_pitch_mm: float = 0.25 # preview PNG resolution (mm/px); smaller = larger image + bit_diameter_mm: float | None = None # caps the preview's cone footprint radius + progress: bool = False # print progress to stdout (set by the CLI; silent as a library) + + +# --- rasterisation ----------------------------------------------------------- + + +def load_png_to_mask(png_path: Path, threshold: int): + """Load a PNG and threshold it to a boolean ink mask (True = carve).""" + np, Image, _ = _require_deps() + img = Image.open(png_path).convert("L") + arr = np.asarray(img) + return arr < threshold + + +# --- skeleton graph tracing -------------------------------------------------- + +_NBR8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] + + +def _trace_skeleton(skel) -> list[list[tuple[int, int]]]: + """Trace a 1-pixel skeleton into ordered polylines of (row, col) pixels. + + Edges run between *nodes* (skeleton pixels whose 8-neighbour degree != 2: + endpoints and junctions). Pure loops (rings of degree-2 pixels, e.g. the + bowl of an 'O') are emitted as closed polylines. + """ + import numpy as np + + H, W = skel.shape + rows, cols = np.nonzero(skel) + pts = set(zip(rows.tolist(), cols.tolist(), strict=True)) + + nbrs: dict[tuple[int, int], list[tuple[int, int]]] = {} + for r, c in pts: + ns = [] + for dr, dc in _NBR8: + p = (r + dr, c + dc) + if p in pts: + ns.append(p) + nbrs[(r, c)] = ns + + def deg(p: tuple[int, int]) -> int: + return len(nbrs[p]) + + visited: set[tuple[tuple[int, int], tuple[int, int]]] = set() + + def key(a, b): + return (a, b) if a <= b else (b, a) + + def walk(start, first) -> list[tuple[int, int]]: + path = [start, first] + visited.add(key(start, first)) + prev, cur = start, first + while deg(cur) == 2: + nxt = [p for p in nbrs[cur] if p != prev] + if not nxt: + break + nn = nxt[0] + k = key(cur, nn) + if k in visited: + break + visited.add(k) + prev, cur = cur, nn + path.append(cur) + return path + + polylines: list[list[tuple[int, int]]] = [] + + # Branches anchored on nodes (endpoints/junctions). + nodes = [p for p in pts if deg(p) != 2] + for n in nodes: + if deg(n) == 0: # isolated pixel (e.g. a tiny dot's 1-px medial) -> a point + polylines.append([n]) + continue + for nb in list(nbrs[n]): + if key(n, nb) in visited: + continue + polylines.append(walk(n, nb)) + + # Remaining pure loops (all degree-2, untouched by the node pass). + for p in pts: + if deg(p) != 2: + continue + for nb in nbrs[p]: + if key(p, nb) in visited: + continue + path = walk(p, nb) + if path[-1] != p: # close the ring + path.append(p) + polylines.append(path) + break + + return polylines + + +def _prune_spurs( + polylines: list[list[tuple[int, int]]], + deg: dict[tuple[int, int], int], + min_len_px: float, +) -> list[list[tuple[int, int]]]: + """Drop short whisker branches (one free end) that only add noise.""" + out = [] + for pl in polylines: + a, b = pl[0], pl[-1] + is_spur = (deg.get(a, 0) == 1) != (deg.get(b, 0) == 1) # exactly one endpoint + if is_spur: + length = sum( + math.hypot(pl[i + 1][0] - pl[i][0], pl[i + 1][1] - pl[i][1]) + for i in range(len(pl) - 1) + ) + if length < min_len_px: + continue + out.append(pl) + return out + + +# --- geometry ---------------------------------------------------------------- + + +def _depth_from_clearance(r_mm: float, opts: VCarveOpts) -> float: + """Depth (mm, positive) to make a V-bit rim touch edges ``r_mm`` away.""" + tan_t = math.tan(math.radians(opts.half_angle_deg)) + d = r_mm / tan_t + if d > opts.max_depth_mm: + d = opts.max_depth_mm + return d + + +def _rdp(points: list[tuple[float, float, float]], tol: float) -> list[tuple[float, float, float]]: + """Ramer-Douglas-Peucker on (x, y, z) polylines (Euclidean in 3D).""" + if len(points) < 3: + return points + a = points[0] + b = points[-1] + ax, ay, az = a + bx, by, bz = b + abx, aby, abz = bx - ax, by - ay, bz - az + ab2 = abx * abx + aby * aby + abz * abz + dmax = 0.0 + idx = 0 + for i in range(1, len(points) - 1): + px, py, pz = points[i] + if ab2 == 0.0: + d = math.dist((px, py, pz), (ax, ay, az)) + else: + t = ((px - ax) * abx + (py - ay) * aby + (pz - az) * abz) / ab2 + t = max(0.0, min(1.0, t)) + proj = (ax + t * abx, ay + t * aby, az + t * abz) + d = math.dist((px, py, pz), proj) + if d > dmax: + dmax = d + idx = i + if dmax <= tol: + return [a, b] + left = _rdp(points[: idx + 1], tol) + right = _rdp(points[idx:], tol) + return left[:-1] + right + + +@dataclass +class ToolPath: + """One connected carve: (X, Y, target_depth) vertices, optionally with arcs. + + ``arcs`` maps a segment index ``i`` to ``(cx, cy, cw)``, meaning the move + from ``pts[i]`` to ``pts[i+1]`` is a circular arc about centre ``(cx, cy)`` + (``cw=True`` -> G02, else G03) rather than a straight line. Segments absent + from the map are straight. Fill/spur paths leave ``arcs`` empty. + """ + + pts: list[tuple[float, float, float]] # X_mm, Y_mm, depth_mm (>=0) + arcs: dict[int, tuple[float, float, bool]] = field(default_factory=dict) + + @property + def max_depth(self) -> float: + return max((p[2] for p in self.pts), default=0.0) + + def reversed(self) -> ToolPath: + """Reverse traversal, remapping arc segment indices and flipping cw.""" + n = len(self.pts) + rpts = list(reversed(self.pts)) + rarcs = {n - 2 - i: (cx, cy, not cw) for i, (cx, cy, cw) in self.arcs.items()} + return ToolPath(rpts, rarcs) + + +def _polylines_to_toolpaths(polylines, dist, ox_mm, oy_mm, pitch, opts: VCarveOpts): + """Convert medial (row, col) polylines to machine-space ToolPaths. + + Pixel (r, c) maps to SVG-mm ``(ox + (c+.5)*pitch, oy + (r+.5)*pitch)``; Y is + then flipped to the bed. Depth = clearance/tan(t). The centreline is + RDP-decimated at ``chord_tol``. When ``--fill-arcs`` is enabled, circular + runs are additionally fitted to arcs (helical where depth ramps).""" + out: list[ToolPath] = [] + for pl in polylines: + raw: list[tuple[float, float, float]] = [] + for r, c in pl: + x_svg = ox_mm + (c + 0.5) * pitch + y_svg = oy_mm + (r + 0.5) * pitch + depth = _depth_from_clearance(float(dist[r, c]) * pitch, opts) + X = opts.x0 + x_svg + Y = opts.y0 + (opts.page_h_mm - y_svg) # flip SVG-down to bed-up + raw.append((X, Y, depth)) + if opts.min_depth_mm > 0.0 and raw and max(p[2] for p in raw) < opts.min_depth_mm: + raw = [(x, y, opts.min_depth_mm) for (x, y, _z) in raw] + + verts = _rdp(raw, opts.chord_tol_mm) + arcs: dict[int, tuple[float, float, bool]] = {} + if opts.fill_arcs and len(verts) >= 3: # opt-in circular/helical arc fitting + xy = [(x, y) for (x, y, _z) in verts] + vidx, arcs = _fit_arcs(xy, opts.fill_arc_tol_mm, opts.chord_tol_mm) + verts = [verts[i] for i in vidx] + if len(verts) >= 2 or (len(verts) == 1 and verts[0][2] > 0): + out.append(ToolPath(verts, arcs)) + return out + + +_RIDGE_GRAD_MAX = 0.85 # medial ridge = where the distance-transform gradient dips below 1 + + +def _medial_ridge_skel(mask): + """Skeleton from the distance-transform *ridge* (where |grad(EDT)| dips below + 1). Unlike thinning-based ``medial_axis``, this does not eat the geometric + arms of a convex polygon -- but it is noisier, so it's used only to repair + the few shapes ``medial_axis`` collapses (see :func:`_medial_skeleton`).""" + np, _, _ = _require_deps() + from scipy.ndimage import distance_transform_edt + from skimage.morphology import skeletonize + + edt = distance_transform_edt(mask) + gy, gx = np.gradient(edt) + return skeletonize(mask & (np.hypot(gx, gy) < _RIDGE_GRAD_MAX)) + + +def _medial_skeleton(mask): + """Medial-axis skeleton + clearance, robust to convex collapse. + + ``skimage.medial_axis`` is clean for typical glyphs but, being thinning-based, + collapses clean *convex polygons* (e.g. a filled diamond) to a couple of + pixels -- which then carve as a round dot instead of a diamond. We keep + ``medial_axis`` everywhere, then per connected component detect a collapse + (skeleton far too short for the shape) that is *not* genuinely round (a disk + or tiny dot has a point medial legitimately) and repair only those with the + distance-ridge skeleton.""" + np, _, medial_axis = _require_deps() + from scipy.ndimage import label + + skel, dist = medial_axis(mask, return_distance=True) + lbl, ncc = label(mask) + ridge = None + for i in range(1, ncc + 1): + comp = lbl == i + area = int(comp.sum()) + if area < 60: # tiny dots: medial_axis handles them; ridge would just add noise + continue + ys, xs = np.nonzero(comp) + maxdim = max(int(xs.max() - xs.min()), int(ys.max() - ys.min())) + 1 + if int((skel & comp).sum()) >= 0.2 * maxdim: + continue # skeleton long enough -> not collapsed + r = float(dist[comp].max()) + if 1.0 - min(1.0, math.pi * r * r / area) < 0.1: + continue # round (disk/dot): a point medial is correct, keep medial_axis + if ridge is None: # non-round collapse (diamond, square, ...) -> repair + ridge = _medial_ridge_skel(mask) + skel = (skel & ~comp) | (ridge & comp) + return skel, dist + + +def _mask_medial_polylines(mask, opts: VCarveOpts, pitch: float): + """Medial axis of ``mask`` -> spur-pruned (row, col) polylines + dist array.""" + np, _, _ = _require_deps() + skel, dist = _medial_skeleton(mask) + polylines = _trace_skeleton(skel) + rows, cols = np.nonzero(skel) + pts = set(zip(rows.tolist(), cols.tolist(), strict=True)) + degmap = { + (r, c): sum(1 for dr, dc in _NBR8 if (r + dr, c + dc) in pts) for r, c in pts + } + polylines = _prune_spurs(polylines, degmap, opts.prune_mm / pitch) + return polylines, dist + + +def build_toolpaths(mask, opts: VCarveOpts) -> list[ToolPath]: + """Whole-mask medial-axis carve (used for the ``--png`` path).""" + polylines, dist = _mask_medial_polylines(mask, opts, opts.pitch_mm) + return _polylines_to_toolpaths(polylines, dist, 0.0, 0.0, opts.pitch_mm, opts) + + +# --- vector medial axis (Voronoi of the boundary) --------------------------- +# The medial axis is the interior subset of the Voronoi diagram of the shape's +# densely-sampled outline, with the "whiskers" toward each boundary sample +# pruned. Computed straight from the SVG vector outline -- no rasterisation, so +# it is robust where a raster skeletoniser collapses clean convex glyphs (a +# filled diamond) or drops tiny dots, and it needs no Inkscape/DPI. + + +def _close_ring(ring): + return list(ring) + [ring[0]] if len(ring) >= 2 and ring[0] != ring[-1] else list(ring) + + +def _sample_rings(rings, step): + """Sample every ring outline at ~``step`` spacing. Returns points (Nx2) and a + parallel ``owner`` list of (ring_index, position_along_ring) per point.""" + np, _, _ = _require_deps() + pts: list[tuple[float, float]] = [] + owner: list[tuple[int, int]] = [] + for rid, ring in enumerate(rings): + r = _close_ring(ring) + pos = 0 + for i in range(len(r) - 1): + ax, ay = r[i] + bx, by = r[i + 1] + n = max(1, int(math.ceil(math.hypot(bx - ax, by - ay) / step))) + for k in range(n): + f = k / n + pts.append((ax + (bx - ax) * f, ay + (by - ay) * f)) + owner.append((rid, pos)) + pos += 1 + return np.asarray(pts, dtype=float), owner + + +def _point_in_polygon(P, rings): + """Even-odd inside test for query points ``P`` (Nx2) vs ``rings`` (counters + handled by the odd-crossing rule). Vectorised over ``P``.""" + np, _, _ = _require_deps() + inside = np.zeros(len(P), dtype=bool) + px, py = P[:, 0], P[:, 1] + for ring in rings: + r = _close_ring(ring) + for i in range(len(r) - 1): + x1, y1 = r[i] + x2, y2 = r[i + 1] + cond = (y1 > py) != (y2 > py) + xint = x1 + (py - y1) * (x2 - x1) / ((y2 - y1) + 1e-12) + inside ^= cond & (px < xint) + return inside + + +def _trace_graph(adj): + """Trace an undirected graph (node -> set of neighbours) into polylines of + node indices: branches between degree!=2 nodes, plus any pure loops.""" + deg = {v: len(ns) for v, ns in adj.items()} + visited: set[tuple[int, int]] = set() + + def key(a, b): + return (a, b) if a <= b else (b, a) + + def walk(s, f): + path = [s, f] + visited.add(key(s, f)) + prev, cur = s, f + while deg.get(cur, 0) == 2: + nxt = [x for x in adj[cur] if x != prev] + if not nxt or key(cur, nxt[0]) in visited: + break + visited.add(key(cur, nxt[0])) + prev, cur = cur, nxt[0] + path.append(cur) + return path + + polys = [] + for v in [v for v in adj if deg[v] != 2]: + for nb in list(adj[v]): + if key(v, nb) not in visited: + polys.append(walk(v, nb)) + for v in adj: # remaining pure loops (all degree 2) + if deg[v] == 2: + for nb in adj[v]: + if key(v, nb) not in visited: + polys.append(walk(v, nb)) + break + return polys, deg + + +def _voronoi_medial(rings, opts: VCarveOpts): + """Medial axis of the polygon ``rings`` (SVG mm) as polylines of + ``(x, y, clearance_mm)``, via the Voronoi diagram of its sampled boundary.""" + import collections + + np, _, _ = _require_deps() + from scipy.spatial import Voronoi, cKDTree + + allp = np.array([p for r in rings for p in r], dtype=float) if rings else np.zeros((0, 2)) + maxdim = float(np.ptp(allp, axis=0).max()) if len(allp) else 0.0 + + def dot(): # degenerate (tiny/round) -> a single plunge point at the centroid + if not len(allp): + return [] + c = allp.mean(axis=0) + clr = float(np.min(np.hypot(allp[:, 0] - c[0], allp[:, 1] - c[1]))) or maxdim / 2.0 + return [[(float(c[0]), float(c[1]), clr)]] + + B, owner = _sample_rings(rings, opts.medial_step_mm) + if len(B) < 4 or maxdim <= 0: + return dot() + try: + vor = Voronoi(B) + except Exception: + return dot() + V = vor.vertices + if len(V) == 0: + return dot() + inside = _point_in_polygon(V, rings) + clr, _idx = cKDTree(B).query(V) + valid = inside & np.isfinite(clr) & (clr < maxdim) # drop numerical junk vertices + + ring_counts = collections.Counter(o[0] for o in owner) + adj: dict[int, set[int]] = collections.defaultdict(set) + for (a, b), (p, q) in zip(vor.ridge_vertices, vor.ridge_points, strict=True): + if a < 0 or b < 0 or not (valid[a] and valid[b]): + continue + (rp, ip), (rq, iq) = owner[p], owner[q] + if rp == rq: # whisker: bisector of two nearby samples on the same ring + n = ring_counts[rp] + if min((ip - iq) % n, (iq - ip) % n) <= opts.medial_hair_adj: + continue + adj[a].add(b) + adj[b].add(a) + if not adj: + return dot() + + polys, deg = _trace_graph(adj) + # Drop residual short endpoint spurs (keep real arms/corner spurs). + def keep(pl): + if deg.get(pl[0], 0) != 1 and deg.get(pl[-1], 0) != 1: + return True + length = sum(math.dist(V[pl[i]], V[pl[i + 1]]) for i in range(len(pl) - 1)) + return length >= opts.prune_mm + polys = [pl for pl in polys if keep(pl)] + + out = [[(float(V[i][0]), float(V[i][1]), float(clr[i])) for i in pl] for pl in polys] + return out or dot() + + +def _clearance_polylines_to_toolpaths(polys, opts: VCarveOpts) -> list[ToolPath]: + """(x_svg, y_svg, clearance_mm) medial polylines -> machine-space ToolPaths. + + Depth = clearance/tan(t); Y is flipped to the bed; short faint features are + floored to ``--min-depth``; optional ``--fill-arcs`` fits circular runs.""" + out: list[ToolPath] = [] + for pl in polys: + raw = [ + (opts.x0 + x, opts.y0 + (opts.page_h_mm - y), _depth_from_clearance(clr, opts)) + for (x, y, clr) in pl + ] + if opts.min_depth_mm > 0.0 and raw and max(p[2] for p in raw) < opts.min_depth_mm: + raw = [(x, y, opts.min_depth_mm) for (x, y, _z) in raw] + verts = _rdp(raw, opts.chord_tol_mm) + arcs: dict[int, tuple[float, float, bool]] = {} + if opts.fill_arcs and len(verts) >= 3: + xy = [(x, y) for (x, y, _z) in verts] + vidx, arcs = _fit_arcs(xy, opts.fill_arc_tol_mm, opts.chord_tol_mm) + verts = [verts[i] for i in vidx] + if len(verts) >= 2 or (len(verts) == 1 and verts[0][2] > 0): + out.append(ToolPath(verts, arcs)) + return out + + +def build_fill_toolpaths(fill_elems, opts: VCarveOpts) -> list[ToolPath]: + """Vector medial-axis carve: compute the Voronoi medial of each filled + element's outline directly (no rasterisation) and convert to toolpaths.""" + out: list[ToolPath] = [] + n = len(fill_elems) + for k, (elem_id, rings) in enumerate(fill_elems, 1): + if not rings: + continue + t0 = time.time() + _progress(opts, f"fill {k}/{n} {elem_id!r} ({len(rings)} ring(s)): vector medial...") + polys = _voronoi_medial(rings, opts) + tps = _clearance_polylines_to_toolpaths(polys, opts) + out += tps + _progress(opts, f"fill {k}/{n} {elem_id!r}: {len(tps)} toolpaths ({time.time() - t0:.1f}s)") + return out + + +def _order_paths(paths: list[ToolPath]) -> list[ToolPath]: + """Greedy nearest-neighbour ordering to shorten rapid travel.""" + if not paths: + return [] + remaining = list(paths) + ordered = [remaining.pop(0)] + while remaining: + cx, cy, _ = ordered[-1].pts[-1] + best_i = 0 + best_d = float("inf") + best_rev = False + for i, p in enumerate(remaining): + sx, sy, _ = p.pts[0] + ex, ey, _ = p.pts[-1] + ds = (sx - cx) ** 2 + (sy - cy) ** 2 + de = (ex - cx) ** 2 + (ey - cy) ** 2 + if ds < best_d: + best_d, best_i, best_rev = ds, i, False + if de < best_d: + best_d, best_i, best_rev = de, i, True + nxt = remaining.pop(best_i) + if best_rev: + nxt = nxt.reversed() + ordered.append(nxt) + return ordered + + +def _passes_for( + path: ToolPath, opts: VCarveOpts +) -> Iterator[tuple[list[tuple[float, float, float]], dict[int, tuple[float, float, bool]]]]: + """Yield serpentine depth passes as ``(points, arcs)``. + + Deep grooves are reached in ``stepdown`` increments; alternate passes run + in opposite directions so the tool steps deeper at the shared end rather + than lifting and repositioning. ``arcs`` maps segment index -> (cx, cy, cw) + in the current traversal direction. + """ + dmax = path.max_depth + n = max(1, math.ceil(dmax / opts.stepdown_mm - 1e-9)) if dmax > 0 else 1 + m = len(path.pts) + for k in range(n): + floor = (k + 1) * opts.stepdown_mm + if k % 2 == 0: + seq = [(x, y, -min(depth, floor)) for (x, y, depth) in path.pts] + arcs = path.arcs + else: + seq = [(x, y, -min(depth, floor)) for (x, y, depth) in reversed(path.pts)] + arcs = {m - 2 - i: (cx, cy, not cw) for i, (cx, cy, cw) in path.arcs.items()} + yield seq, arcs + + +def emit_gcode(paths: list[ToolPath], opts: VCarveOpts) -> str: + ordered = _order_paths(paths) + out: list[str] = [] + w = out.append + + total_depth = max((p.max_depth for p in ordered), default=0.0) + w("(Generated by rollingmill.vcarve -- medial-axis V-carve)") + w( + f"(V-bit half-angle: {_fmt(opts.half_angle_deg)} deg, pitch: {_fmt(opts.pitch_mm)} mm, " + f"max depth used: {_fmt(total_depth)} mm)" + ) + w(f"(Paths: {len(ordered)}, stepdown: {_fmt(opts.stepdown_mm)} mm)") + w("%") + w("O00000001") + w("G21") # mm + w("G90") # absolute + w("G17") # XY plane + if opts.workspace: + w(opts.workspace) + w(f"F{_fmtp(opts.feed)}") + w(f"S{opts.spindle_rpm}") + w("M03") # spindle on (CW) + w(f"G00 Z{_fmtp(opts.z_safe)}") + + cur_feed = "cut" # track so we only emit F when the regime changes + for path in ordered: + first = True + for seg, arcs in _passes_for(path, opts): + sx, sy, sz = seg[0] + if first: + w(f"G00 X{_fmtp(sx)} Y{_fmtp(sy)}") + w(f"G01 Z{_fmtp(sz)} F{_fmtp(opts.plunge_feed)}") + w(f"F{_fmtp(opts.feed)}") + cur_feed = "cut" + first = False + prev = (sx, sy, sz) + else: + # Continuing at the shared end: step Z deeper in place. + if abs(sz - prev[2]) > 1e-6: + w(f"G01 Z{_fmtp(sz)} F{_fmtp(opts.plunge_feed)}") + cur_feed = "plunge" + prev = (sx, sy, sz) + for i in range(len(seg) - 1): + x, y, z = seg[i + 1] + arc = arcs.get(i) + if arc is not None: # circular arc; helical (with Z) if depth ramps + cx, cy, cw = arc + op = "G02" if cw else "G03" + irel, jrel = cx - seg[i][0], cy - seg[i][1] + zword = f" Z{_fmtp(z)}" if abs(z - seg[i][2]) > 1e-6 else "" + feed = f" F{_fmtp(opts.feed)}" if cur_feed != "cut" else "" + w(f"{op} X{_fmtp(x)} Y{_fmtp(y)}{zword} I{_fmtp(irel)} J{_fmtp(jrel)}{feed}") + cur_feed = "cut" + elif cur_feed != "cut": + w(f"G01 X{_fmtp(x)} Y{_fmtp(y)} Z{_fmtp(z)} F{_fmtp(opts.feed)}") + cur_feed = "cut" + else: + w(f"G01 X{_fmtp(x)} Y{_fmtp(y)} Z{_fmtp(z)}") + prev = (x, y, z) + w(f"G00 Z{_fmtp(opts.z_safe)}") + + if opts.return_home: + w("G00 X0.0 Y0.0") + w("M05") + w("M30") + return "\n".join(out) + "\n" + + +# --- SVG classification: filled regions vs stroke-only paths ---------------- + +# svgelements is parsed at ppi=25.4 so that a "300mm" page with a 0..300 +# viewBox yields coordinates and stroke widths directly in millimetres. +_PPI_MM = 25.4 + + +@dataclass +class StrokeSpec: + """A stroke-only feature: mm subpaths (SVG y-down) and its paint style.""" + + subpaths: list[list[tuple[float, float]]] + width_mm: float + linejoin: str = "miter" # miter | bevel | round + linecap: str = "butt" # butt | square | round + miterlimit: float = 4.0 + marker_start: str | None = None + marker_end: str | None = None + is_marker: bool = False # geometry expanded from a marker (no arc-fitting) + # Per-subpath, per-point flag: did this point come from a curved (bezier/arc) + # segment? Only True runs are arc-fit candidates. Empty = treat all as curve. + subpath_curves: list[list[bool]] = field(default_factory=list) + + +def _svg_shapes(svg_path: Path): + """Yield rendered (non-defs) shape elements from ``svg_path``, in mm.""" + svgelements = _require_svg() + svg = svgelements.SVG.parse(str(svg_path), reify=True, ppi=_PPI_MM) + for e in svg.elements(): + if isinstance(e, svgelements.Shape) and not isinstance( + e, (svgelements.SVG, svgelements.Group) + ): + yield svgelements, e + + +def _classify(e) -> str | None: + """'fill' if the shape has a fill, else 'stroke' if it has a visible + stroke, else None (invisible).""" + fill = getattr(e, "fill", None) + has_fill = fill is not None and fill.value is not None + stroke = getattr(e, "stroke", None) + try: + width = float(e.implicit_stroke_width or 0.0) + except (TypeError, ValueError): + width = 0.0 + has_stroke = stroke is not None and stroke.value is not None and width > 0.0 + if has_fill: + return "fill" + if has_stroke: + return "stroke" + return None + + +_CURVE_SEGS = ("CubicBezier", "QuadraticBezier", "Arc") + + +def _flatten(shape, step_mm: float) -> list[tuple[list[tuple[float, float]], list[bool]]]: + """Flatten a path/shape into subpaths, sampling curves by length. + + Returns ``(points, curve_flags)`` per subpath; ``curve_flags[i]`` is True + when point ``i`` came from a curved segment (bezier/arc). Straight (line) + points are False, so only bezier/arc-origin runs are arc-fit candidates.""" + subpaths: list[tuple[list[tuple[float, float]], list[bool]]] = [] + cur: list[tuple[float, float]] = [] + curf: list[bool] = [] + + def close(): + if len(cur) >= 2: + subpaths.append((cur[:], curf[:])) + + for seg in shape.segments(): + name = type(seg).__name__ + if name == "Move": + close() + p = seg.end + cur, curf = [(float(p.x), float(p.y))], [False] + elif name == "Close": + if cur: + cur.append(cur[0]) + curf.append(False) + close() + cur, curf = [], [] + else: # Line / CubicBezier / QuadraticBezier / Arc + is_curve = name in _CURVE_SEGS + try: + length = float(seg.length(error=1e-4)) + except TypeError: + length = float(seg.length()) + n = max(1, math.ceil(length / step_mm)) + for i in range(1, n + 1): + p = seg.point(i / n) + cur.append((float(p.x), float(p.y))) + curf.append(is_curve) + close() + return subpaths + + +# A plain V-bit centreline groove rounds off convex corners (by w/2) and line +# ends (to a semicircle). To recover the sharp features the stroke's join/cap +# style implies, we add short *spurs* that ramp the depth to zero at the corner +# -- the tool rides up out of the material exactly at the point. This is the +# analytic medial axis of the stroke outline at each join/cap. + +_CORNER_ANGLE_RAD = math.radians(18.0) # min turn to count a flattened vertex as a corner + + +def _unit(ax: float, ay: float) -> tuple[float, float]: + m = math.hypot(ax, ay) + return (0.0, 0.0) if m < 1e-12 else (ax / m, ay / m) + + +def _is_closed(sp: list[tuple[float, float]]) -> bool: + return len(sp) >= 3 and math.dist(sp[0], sp[-1]) < 1e-6 + + +def _turn(a, p, b) -> tuple[tuple[float, float], tuple[float, float], float]: + """Return (dir_in, dir_out, turn angle) at vertex p between a->p->b.""" + din = _unit(p[0] - a[0], p[1] - a[1]) + dout = _unit(b[0] - p[0], b[1] - p[1]) + if din == (0.0, 0.0) or dout == (0.0, 0.0): + return din, dout, 0.0 + dot = max(-1.0, min(1.0, din[0] * dout[0] + din[1] * dout[1])) + return din, dout, math.acos(dot) + + +def _corner_indices(sp, closed: bool) -> list[int]: + n = len(sp) + idx = [] + if closed: + m = n - 1 # last vertex duplicates the first + for i in range(m): + _, _, turn = _turn(sp[(i - 1) % m], sp[i], sp[(i + 1) % m]) + if turn > _CORNER_ANGLE_RAD: + idx.append(i) + else: + for i in range(1, n - 1): + _, _, turn = _turn(sp[i - 1], sp[i], sp[i + 1]) + if turn > _CORNER_ANGLE_RAD: + idx.append(i) + return idx + + +def _join_spurs(sp, i, closed, r, depth, spec: StrokeSpec): + """Tapering spur(s) (in SVG-mm (x, y, depth)) that sharpen a join at sp[i].""" + if spec.linejoin == "round": + return [] + m = len(sp) - 1 if closed else len(sp) + p = sp[i] + a = sp[(i - 1) % m] if closed else sp[i - 1] + b = sp[(i + 1) % m] if closed else sp[i + 1] + din, dout, turn = _turn(a, p, b) + if turn <= _CORNER_ANGLE_RAD: + return [] + mdir = _unit(din[0] - dout[0], din[1] - dout[1]) # outer bisector (toward miter tip) + if mdir == (0.0, 0.0): + return [] + sin_half = math.cos(turn / 2.0) # sin(interior/2), interior = pi - turn + if sin_half < 1e-6: + return [] + bevel = spec.linejoin == "bevel" or (1.0 / sin_half) > spec.miterlimit + if not bevel: + M = r / sin_half + tip = (p[0] + mdir[0] * M, p[1] + mdir[1] * M) + return [[(p[0], p[1], depth), (tip[0], tip[1], 0.0)]] + + def outer_normal(d): + c1, c2 = (-d[1], d[0]), (d[1], -d[0]) + return c1 if (c1[0] * mdir[0] + c1[1] * mdir[1]) >= 0 else c2 + + spurs = [] + for d in (din, dout): + nrm = outer_normal(d) + c = (p[0] + nrm[0] * r, p[1] + nrm[1] * r) + spurs.append([(p[0], p[1], depth), (c[0], c[1], 0.0)]) + return spurs + + +def _cap_spurs(sp, r, depth, linecap, at_start: bool): + """Spurs that square a butt/square line end (round caps need none).""" + if linecap == "round": + return [] + if at_start: + e, nxt = sp[0], sp[1] + into = _unit(nxt[0] - e[0], nxt[1] - e[1]) + else: + e, prv = sp[-1], sp[-2] + into = _unit(prv[0] - e[0], prv[1] - e[1]) + if into == (0.0, 0.0): + return [] + out_dir = (-into[0], -into[1]) + perp = (-into[1], into[0]) + if linecap == "square": + base = e + off = (out_dir[0] * r, out_dir[1] * r) + else: # butt: base pulled r into the path, corners at the endpoint + base = (e[0] + into[0] * r, e[1] + into[1] * r) + off = (0.0, 0.0) + c1 = (e[0] + off[0] + perp[0] * r, e[1] + off[1] + perp[1] * r) + c2 = (e[0] + off[0] - perp[0] * r, e[1] + off[1] - perp[1] * r) + return [ + [(base[0], base[1], depth), (c1[0], c1[1], 0.0)], + [(base[0], base[1], depth), (c2[0], c2[1], 0.0)], + ] + + +def _trim_polyline(sp, t_start: float, t_end: float, flags=None): + """Shorten a polyline by arc length ``t_start``/``t_end`` from each end. + + If ``flags`` (per-point bool) is given, trims it in parallel and returns + ``(pts, flags)``; interpolated end points are marked non-curve (False).""" + def trim_front(pts, fl, t): + if t <= 0.0: + return pts, fl + acc = 0.0 + for i in range(len(pts) - 1): + seg = math.dist(pts[i], pts[i + 1]) + if acc + seg >= t: + f = (t - acc) / seg if seg > 0 else 0.0 + nx = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * f + ny = pts[i][1] + (pts[i + 1][1] - pts[i][1]) * f + return [(nx, ny)] + pts[i + 1 :], ([False] + fl[i + 1 :] if fl is not None else None) + acc += seg + return pts[-1:], (fl[-1:] if fl is not None else None) + + pts, fl = trim_front(list(sp), list(flags) if flags is not None else None, t_start) + rpts, rfl = trim_front(list(reversed(pts)), list(reversed(fl)) if fl is not None else None, t_end) + pts, fl = list(reversed(rpts)), (list(reversed(rfl)) if rfl is not None else None) + return (pts, fl) if flags is not None else pts + + +# --- circular-arc fitting for stroke centrelines ---------------------------- +# Straight moves stream as G01; a run of points that lies on a circle becomes a +# single G02/G03, which the mill cuts faster and more accurately than a chain +# of chords. Fitting is done in machine coords (after the Y-flip) so the arc +# direction (cw) is already correct for output. + + +def _solve3(A, b): + """Solve a 3x3 linear system by Cramer's rule; None if near-singular.""" + def det3(m): + return ( + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + ) + + d = det3(A) + if abs(d) < 1e-12: + return None + out = [] + for c in range(3): + M = [row[:] for row in A] + for ri in range(3): + M[ri][c] = b[ri] + out.append(det3(M) / d) + return out[0], out[1], out[2] + + +def _circle_fit(pts): + """Algebraic (Kasa) circle fit -> (cx, cy, r), or None if degenerate.""" + if len(pts) < 3: + return None + Sx = Sy = Sxx = Syy = Sxy = Sxxx = Syyy = Sxyy = Sxxy = 0.0 + for x, y in pts: + Sx += x + Sy += y + Sxx += x * x + Syy += y * y + Sxy += x * y + Sxxx += x * x * x + Syyy += y * y * y + Sxyy += x * y * y + Sxxy += x * x * y + n = len(pts) + A = [[Sxx, Sxy, Sx], [Sxy, Syy, Sy], [Sx, Sy, float(n)]] + b = [-(Sxxx + Sxyy), -(Sxxy + Syyy), -(Sxx + Syy)] + sol = _solve3(A, b) + if sol is None: + return None + D, E, F = sol + cx, cy = -D / 2.0, -E / 2.0 + r2 = cx * cx + cy * cy - F + if r2 <= 0.0: + return None + return cx, cy, math.sqrt(r2) + + +def _arc_sweep(pts, cx, cy) -> float: + """Signed total angle (radians) swept about (cx, cy) along ``pts``.""" + ang = [math.atan2(py - cy, px - cx) for px, py in pts] + total = 0.0 + for a, b in zip(ang, ang[1:], strict=False): + d = b - a + while d > math.pi: + d -= 2 * math.pi + while d < -math.pi: + d += 2 * math.pi + total += d + return total + + +def _rdp2(pts, tol): + """Ramer-Douglas-Peucker on 2D (x, y) points.""" + if len(pts) < 3: + return list(pts) + a, b = pts[0], pts[-1] + dx, dy = b[0] - a[0], b[1] - a[1] + L2 = dx * dx + dy * dy + dmax, idx = 0.0, 0 + for i in range(1, len(pts) - 1): + px, py = pts[i] + if L2 == 0.0: + d = math.hypot(px - a[0], py - a[1]) + else: + t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / L2)) + d = math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy)) + if d > dmax: + dmax, idx = d, i + if dmax <= tol: + return [a, b] + return _rdp2(pts[: idx + 1], tol)[:-1] + _rdp2(pts[idx:], tol) + + +def _grow_arc(poly, i, tol, min_pts=6, min_sweep_deg=12.0, max_sweep_deg=180.0, max_radius=5000.0, eligible=None): + """Longest circular run starting at ``poly[i]``: (end_index, cx, cy, cw) or None. + + ``eligible`` (per-point bool) restricts the run to a contiguous eligible span, + so arcs are never fitted across straight (line-origin) points.""" + n = len(poly) + end = n - 1 + if eligible is not None: # cap the run at the contiguous eligible span from i + end = i + while end + 1 < n and eligible[end + 1]: + end += 1 + best = None + j = i + min_pts - 1 + while j <= end: + fit = _circle_fit(poly[i : j + 1]) + if fit is None: + break + cx, cy, rad = fit + if rad > max_radius: + break + if max(abs(math.hypot(px - cx, py - cy) - rad) for px, py in poly[i : j + 1]) > tol: + break + sweep = _arc_sweep(poly[i : j + 1], cx, cy) + if abs(sweep) > math.radians(max_sweep_deg): + break + best = (j, cx, cy, sweep) + j += 1 + if best is None: + return None + j, cx, cy, sweep = best + if abs(sweep) < math.radians(min_sweep_deg): + return None + return j, cx, cy, (sweep < 0.0) # machine coords y-up: decreasing angle = cw (G02) + + +def _rdp2_idx(poly, lo, hi, tol): + """RDP over poly[lo..hi] (XY), returning the kept *indices* (incl. lo, hi).""" + if hi - lo < 2: + return list(range(lo, hi + 1)) + a, b = poly[lo], poly[hi] + dx, dy = b[0] - a[0], b[1] - a[1] + L2 = dx * dx + dy * dy + dmax, idx = 0.0, lo + for i in range(lo + 1, hi): + px, py = poly[i] + if L2 == 0.0: + d = math.hypot(px - a[0], py - a[1]) + else: + t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / L2)) + d = math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy)) + if d > dmax: + dmax, idx = d, i + if dmax <= tol: + return [lo, hi] + return _rdp2_idx(poly, lo, idx, tol)[:-1] + _rdp2_idx(poly, idx, hi, tol) + + +def _fit_arcs(poly, arc_tol, line_tol, eligible=None): + """Compress a polyline into (vertex indices, arcs). Arc runs collapse to one + segment each; straight runs are RDP'd to lines. ``arcs`` maps segment index + (position in the returned vertex list) -> (cx, cy, cw). Returning indices + lets callers carry a per-vertex payload (e.g. depth) through the fit. + + ``eligible`` (per-point bool) marks which points may belong to an arc; a + point that is not eligible (e.g. from a straight line segment) is never the + start of, or spanned by, an arc -- so sharp line corners stay corners.""" + n = len(poly) + if n < 2: + return list(range(n)), {} + vidx = [0] + arcs: dict[int, tuple[float, float, bool]] = {} + + def flush(hi): # emit line vertices from the last committed vertex up to hi + if hi <= vidx[-1]: + return + for k in _rdp2_idx(poly, vidx[-1], hi, line_tol)[1:]: + vidx.append(k) + + i = 0 + while i < n - 1: + grown = None + if eligible is None or eligible[i]: + grown = _grow_arc(poly, i, arc_tol, eligible=eligible) + if grown is not None: + j, cx, cy, cw = grown + flush(i) # commit the straight run up to poly[i] + vidx.append(j) + arcs[len(vidx) - 2] = (cx, cy, cw) + i = j + else: + i += 1 + flush(n - 1) + return vidx, arcs + + +def _arc_points(p0, p1, cx, cy, cw, chord): + """Sample an arc from p0 to p1 about (cx, cy) at ~``chord`` spacing (excl. p0).""" + rad = math.hypot(p0[0] - cx, p0[1] - cy) + a0 = math.atan2(p0[1] - cy, p0[0] - cx) + a1 = math.atan2(p1[1] - cy, p1[0] - cx) + if cw: + while a1 > a0: + a1 -= 2 * math.pi + else: + while a1 < a0: + a1 += 2 * math.pi + sweep = a1 - a0 + m = max(1, math.ceil(abs(sweep) * rad / max(chord, 1e-6))) + return [ + (cx + rad * math.cos(a0 + sweep * k / m), cy + rad * math.sin(a0 + sweep * k / m)) + for k in range(1, m + 1) + ] + + +def _densify(path: ToolPath, chord: float) -> list[tuple[float, float, float]]: + """Expand a ToolPath to a dense (X, Y, depth) polyline, sampling any arcs.""" + if not path.pts: + return [] + out = [path.pts[0]] + for i in range(len(path.pts) - 1): + p0, p1 = path.pts[i], path.pts[i + 1] + arc = path.arcs.get(i) + if arc is None: + out.append(p1) + else: + cx, cy, cw = arc + out.extend((x, y, p1[2]) for (x, y) in _arc_points(p0, p1, cx, cy, cw, chord)) + return out + + +def _stroke_toolpaths(spec: StrokeSpec, opts: VCarveOpts) -> list[ToolPath]: + """Centreline groove (with arc fitting) + join/cap sharpening spurs.""" + r = spec.width_mm / 2.0 + depth = _depth_from_clearance(r, opts) + + def flip(x, y): + return (opts.x0 + x, opts.y0 + (opts.page_h_mm - y)) + + def line_tp(pts): # SVG-mm (x, y, depth) -> Y-flipped straight ToolPath + flipped = [(*flip(x, y), d) for (x, y, d) in pts] + flipped = _rdp(flipped, opts.chord_tol_mm) + return ToolPath(flipped) if len(flipped) >= 2 else None + + def main_tp(main_xy, curve_flags): # arc-fitted centreline groove + poly = [flip(x, y) for (x, y) in main_xy] + # Only bezier/arc-origin geometry is an arc candidate; straight line + # segments (triangle corners) and marker geometry are never fitted. + if opts.arcs and not spec.is_marker and len(poly) >= 3: + vidx, arcs = _fit_arcs(poly, opts.arc_tol_mm, opts.chord_tol_mm, eligible=curve_flags) + verts = [poly[i] for i in vidx] + else: + verts, arcs = _rdp2(poly, opts.chord_tol_mm), {} + if len(verts) < 2: + return None + return ToolPath([(x, y, depth) for (x, y) in verts], arcs) + + out: list[ToolPath] = [] + for si, sp in enumerate(spec.subpaths): + if len(sp) < 2: + continue + flags = spec.subpath_curves[si] if si < len(spec.subpath_curves) else [True] * len(sp) + closed = _is_closed(sp) + spurs: list[list[tuple[float, float, float]]] = [] + + # Join sharpening at every sharp vertex. + for i in _corner_indices(sp, closed): + spurs += _join_spurs(sp, i, closed, r, depth, spec) + + # Cap sharpening + groove trimming at open ends. An end covered by a + # marker (an arrowhead) gets no cap *spurs* -- the arrowhead sits on it -- + # but the groove is still trimmed back so its rounded end tucks under the + # marker instead of poking out at the vertex. The marker's own arm tips + # do get capped (so they finish square). + main, main_flags = sp, flags + if not closed: + cap_start = not spec.marker_start + cap_end = not spec.marker_end + if cap_start: + spurs += _cap_spurs(sp, r, depth, spec.linecap, at_start=True) + if cap_end: + spurs += _cap_spurs(sp, r, depth, spec.linecap, at_start=False) + trim_start = (cap_start and spec.linecap == "butt") or bool(spec.marker_start) + trim_end = (cap_end and spec.linecap == "butt") or bool(spec.marker_end) + if trim_start or trim_end: + main, main_flags = _trim_polyline( + sp, r if trim_start else 0.0, r if trim_end else 0.0, flags=flags + ) + + tp = main_tp(main, main_flags) + if tp is not None: + out.append(tp) + for spur in spurs: + tp = line_tp(spur) + if tp is not None: + out.append(tp) + return out + + +def parse_svg_features( + svg_path: Path, opts: VCarveOpts +) -> tuple[set[str], list[StrokeSpec], list[tuple[str, list[list[tuple[float, float]]]]]]: + """Classify shapes; return (stroke ids, stroke specs, filled elements). + + Each filled element is ``(id, rings)`` where ``rings`` is the element's + outline flattened to closed polylines in millimetres (outer + counters), for + the vector medial axis. The list is falsy when there are no fills.""" + stroke_ids: set[str] = set() + strokes: list[StrokeSpec] = [] + fill_elems: list[tuple[str, list[list[tuple[float, float]]]]] = [] + for _svgelements, e in _svg_shapes(svg_path): + kind = _classify(e) + if kind == "fill": + eid = e.values.get("id") or f"fill{len(fill_elems)}" + rings = [pts for pts, _cf in _flatten(e, opts.medial_step_mm) if len(pts) >= 3] + if rings: + fill_elems.append((eid, rings)) + elif kind == "stroke": + eid = e.values.get("id") + if eid: + stroke_ids.add(eid) + flat = _flatten(e, opts.stroke_flatten_mm) + if flat: + try: + mlim = float(e.values.get("stroke-miterlimit") or 4.0) + except (TypeError, ValueError): + mlim = 4.0 + strokes.append( + StrokeSpec( + subpaths=[pts for pts, _cf in flat], + subpath_curves=[cf for _pts, cf in flat], + width_mm=float(e.implicit_stroke_width), + linejoin=e.values.get("stroke-linejoin") or "miter", + linecap=e.values.get("stroke-linecap") or "butt", + miterlimit=mlim, + marker_start=e.values.get("marker-start"), + marker_end=e.values.get("marker-end"), + ) + ) + return stroke_ids, strokes, fill_elems + + +# --- marker (arrowhead) expansion ------------------------------------------- + + +def _parse_marker_defs(svg_path: Path) -> dict: + """Read definitions keyed by id (raw attributes + child paths).""" + import xml.etree.ElementTree as ET + + def local(tag: str) -> str: + return tag.split("}")[-1] + + markers: dict = {} + root = ET.parse(svg_path).getroot() + for m in root.iter(): + if local(m.tag) != "marker": + continue + children = [ + (c.get("d"), c.get("transform"), c.get("style", "") + ";" + (c.get("stroke-width") and f"stroke-width:{c.get('stroke-width')}" or "")) + for c in m.iter() + if local(c.tag) == "path" and c.get("d") + ] + markers[m.get("id")] = { + "refX": float(m.get("refX", 0) or 0), + "refY": float(m.get("refY", 0) or 0), + "orient": m.get("orient", "0") or "0", + "markerWidth": float(m.get("markerWidth", 3) or 3), + "markerHeight": float(m.get("markerHeight", 3) or 3), + "markerUnits": m.get("markerUnits") or "strokeWidth", + "viewBox": m.get("viewBox"), + "children": children, + } + return markers + + +def _style_num(style: str, key: str, default: float) -> float: + for part in (style or "").split(";"): + part = part.strip() + if part.startswith(key + ":"): + try: + return float(part.split(":", 1)[1]) + except ValueError: + return default + return default + + +def _marker_specs(svg_path: Path, strokes: list[StrokeSpec], opts: VCarveOpts) -> list[StrokeSpec]: + """Expand marker-start/-end refs on stroke paths into carveable specs.""" + svgelements = _require_svg() + defs = _parse_marker_defs(svg_path) + if not defs: + return [] + out: list[StrokeSpec] = [] + for spec in strokes: + for ref, at_start in ((spec.marker_start, True), (spec.marker_end, False)): + if not ref: + continue + mid = ref.strip()[5:-1].lstrip("#") if ref.startswith("url(") else ref.lstrip("#") + mdef = defs.get(mid) + if mdef is None or not spec.subpaths: + continue + sp = spec.subpaths[0] if at_start else spec.subpaths[-1] + if at_start: + vertex = sp[0] + tan = (sp[1][0] - sp[0][0], sp[1][1] - sp[0][1]) + else: + vertex = sp[-1] + tan = (sp[-1][0] - sp[-2][0], sp[-1][1] - sp[-2][1]) + mag = math.hypot(*tan) or 1.0 + ux, uy = tan[0] / mag, tan[1] / mag + angle = math.atan2(uy, ux) + orient = mdef["orient"] + if orient == "auto-start-reverse" and at_start: + angle += math.pi + elif orient not in ("auto", "auto-start-reverse"): + try: + angle = math.radians(float(orient)) + except ValueError: + angle = 0.0 + # content -> world scale + vb = mdef["viewBox"] + vbw = float(vb.split()[2]) if vb else mdef["markerWidth"] + s = mdef["markerWidth"] / (vbw or 1.0) + if mdef["markerUnits"] != "userSpaceOnUse": + s *= spec.width_mm + ca, sa = math.cos(angle), math.sin(angle) + refx, refy = mdef["refX"], mdef["refY"] + for cd, ctransform, cstyle in mdef["children"]: + cpath = svgelements.Path(cd) + if ctransform: + cpath *= svgelements.Matrix(ctransform) + content = _flatten(cpath, max(opts.stroke_flatten_mm / max(s, 1e-6), 1e-3)) + world = [] + for subp, _cf in content: + pts = [] + for (px, py) in subp: + lx, ly = (px - refx) * s, (py - refy) * s + wx = vertex[0] + (ca * lx - sa * ly) + wy = vertex[1] + (sa * lx + ca * ly) + pts.append((wx, wy)) + world.append(pts) + cw = _style_num(cstyle, "stroke-width", 1.0) * s + out.append(StrokeSpec(subpaths=world, width_mm=cw, linecap="square", is_marker=True)) + return out + + +# The preview PNG is built at PREVIEW_SS x the requested output resolution +# (``--preview-pitch`` mm/px) and box-downsampled by PREVIEW_SS for antialiasing. +_PREVIEW_SS = 2 + + +def _preview_dims(opts: VCarveOpts) -> tuple[float, int, int, int, int]: + """Return (internal px/mm, internal w, internal h, output w, output h).""" + ppmm = _PREVIEW_SS / max(opts.preview_pitch_mm, 1e-6) + w = max(1, min(8000, round(opts.page_w_mm * ppmm))) + h = max(1, min(8000, round(opts.page_h_mm * ppmm))) + return ppmm, w, h, max(1, round(w / _PREVIEW_SS)), max(1, round(h / _PREVIEW_SS)) + + +def render_preview(paths: list[ToolPath], opts: VCarveOpts, out_png: Path) -> None: + """Write a carved-surface depth-map preview PNG in the drawing's orientation. + + Rather than drawing thin lines, this models what the V-bit actually removes: + at plunge depth ``D`` the cone carves a swath of radius ``D * tan(t)`` around + the toolpath, tapering from ``D`` at the centre to the surface at the rim. + Each pixel is coloured by the **deepest** cut reaching it (deep = blue, + shallow = pale), so overlaps and the bit's finite width show correctly. Built + supersampled and downsampled for antialiasing; Y is flipped back to SVG + orientation so text reads upright. + """ + np, Image, _ = _require_deps() + from scipy.ndimage import grey_dilation + + tan_t = math.tan(math.radians(opts.half_angle_deg)) + d_max = max((p.max_depth for p in paths), default=0.0) + + # Internal (supersampled) resolution: SS x the requested output px/mm. + ppmm, w, h, _out_w, _out_h = _preview_dims(opts) + + # Stamp toolpath centreline depths into L (max where paths overlap), sampling + # each segment at ~pixel spacing with interpolated depth. + L = np.zeros((h, w), dtype=float) + xs: list[float] = [] + ys: list[float] = [] + ds: list[float] = [] + + def screen(X: float, Y: float) -> tuple[float, float]: + y_svg = opts.page_h_mm - (Y - opts.y0) # undo the bed Y-flip + return ((X - opts.x0) * ppmm, y_svg * ppmm) + + for path in paths: + dense = _densify(path, 1.0 / ppmm) # sample arcs to ~1 px before stamping + for (x0, y0, d0), (x1, y1, d1) in zip(dense[:-1], dense[1:], strict=False): + sx0, sy0 = screen(x0, y0) + sx1, sy1 = screen(x1, y1) + n = max(1, math.ceil(math.hypot(sx1 - sx0, sy1 - sy0))) + for i in range(n + 1): + f = i / n + xs.append(sx0 + (sx1 - sx0) * f) + ys.append(sy0 + (sy1 - sy0) * f) + ds.append(d0 + (d1 - d0) * f) + + if xs: + xi = np.clip(np.round(np.array(xs)).astype(int), 0, w - 1) + yi = np.clip(np.round(np.array(ys)).astype(int), 0, h - 1) + np.maximum.at(L, (yi, xi), np.array(ds)) + + if d_max > 0.0 and xs: + # Spread each centreline depth into its V-groove: depth = max over nearby + # toolpath of (D - dist/tan_t). A single big conical kernel is exact but + # slow; because a cone dilated by a cone is a wider cone of the same + # slope, we iterate a small conical dilation instead -- a few passes + # rebuild the same envelope for a fraction of the cost. + r_mm = d_max * tan_t + if opts.bit_diameter_mm is not None: + r_mm = min(r_mm, opts.bit_diameter_mm / 2.0) + rk = 3 # small-kernel radius in px + yy, xx = np.mgrid[-rk : rk + 1, -rk : rk + 1] + dloc = np.hypot(xx, yy) / ppmm + footprint = dloc <= rk / ppmm + 1e-9 + structure = np.where(footprint, -dloc / tan_t, 0.0) + depth = L + for _ in range(max(1, math.ceil(r_mm * ppmm / rk))): + depth = grey_dilation(depth, footprint=footprint, structure=structure) + np.maximum(depth, 0.0, out=depth) + else: + depth = L + + dnorm = d_max or opts.max_depth_mm or 1.0 + t = np.clip(depth / dnorm, 0.0, 1.0) + carved = depth > 1e-6 + r_ch = np.where(carved, 235 - 205 * t, 255) + g_ch = np.where(carved, 235 - 150 * t, 255) + b_ch = np.where(carved, 60 + 150 * t, 255) + rgb = np.stack([r_ch, g_ch, b_ch], axis=-1).clip(0, 255).astype("uint8") + + img = Image.fromarray(rgb, "RGB") + img = img.resize((_out_w, _out_h), Image.LANCZOS) + out_png.parent.mkdir(parents=True, exist_ok=True) + img.save(out_png) + + +def render_preview_svg(paths: list[ToolPath], opts: VCarveOpts, out_svg: Path) -> None: + """Write a vector SVG of the recovered toolpath centrelines, depth-coloured. + + Complements the depth-map PNG: the PNG shows the *removed area*, this shows + the *tool placement* -- each centreline (arcs sampled to a smooth polyline) + drawn as a thin line whose colour tracks cut depth (deep = blue, shallow = + pale). In the drawing's orientation so it overlays the source art.""" + d_max = max((p.max_depth for p in paths), default=0.0) or opts.max_depth_mm or 1.0 + band_w = d_max / 12.0 + + def colour(depth: float) -> str: + t = min(max(depth / d_max, 0.0), 1.0) + return f"rgb({int(235 - 205 * t)},{int(235 - 150 * t)},{int(60 + 150 * t)})" + + def to_svg(X: float, Y: float) -> tuple[float, float]: + return (X - opts.x0, opts.page_h_mm - (Y - opts.y0)) + + elems: list[str] = [] + + def flush(run, dsum, n): + if len(run) >= 2: + pts = " ".join(f"{x:.2f},{y:.2f}" for x, y in run) + elems.append( + f'' + ) + + for path in paths: + dense = _densify(path, 0.2) + if len(dense) < 2: + continue + run = [to_svg(dense[0][0], dense[0][1])] + run_band = int(dense[0][2] / band_w) + dsum, n = dense[0][2], 1 + for X, Y, z in dense[1:]: + p = to_svg(X, Y) + run.append(p) + dsum += z + n += 1 + b = int(z / band_w) + if b != run_band: # colour band changed -> start a new polyline + flush(run, dsum, n) + run, run_band, dsum, n = [p], b, z, 1 + flush(run, dsum, n) + + svg = ( + f'\n' + f'\n' + + "\n".join(elems) + + "\n\n" + ) + out_svg.parent.mkdir(parents=True, exist_ok=True) + out_svg.write_text(svg) + + +def run(svg_or_png: Path, opts: VCarveOpts, is_png: bool) -> str: + t_start = time.time() + paths: list[ToolPath] = [] + if is_png: + _progress(opts, f"loading mask {svg_or_png.name}...") + paths = build_toolpaths(load_png_to_mask(svg_or_png, opts.threshold), opts) + _progress(opts, f"{len(paths)} toolpaths from mask") + else: + _progress(opts, f"parsing {svg_or_png.name}...") + _stroke_ids, strokes, fill_elems = parse_svg_features(svg_or_png, opts) + _progress(opts, f"{len(fill_elems)} filled element(s), {len(strokes)} stroke(s)") + if fill_elems: + paths += build_fill_toolpaths(fill_elems, opts) + n0 = len(paths) + for spec in strokes: + paths += _stroke_toolpaths(spec, opts) + _progress(opts, f"strokes: {len(paths) - n0} toolpaths") + if opts.markers: + n1 = len(paths) + try: + for mspec in _marker_specs(svg_or_png, strokes, opts): + paths += _stroke_toolpaths(mspec, opts) + if len(paths) > n1: + _progress(opts, f"markers: {len(paths) - n1} toolpaths") + except Exception as e: # markers are best-effort; never fail the carve + print(f"warning: could not expand markers ({e}); arrowheads skipped") + if opts.preview_path is not None: + out_svg = opts.preview_path.with_suffix(".svg") + _, _, _, ow, oh = _preview_dims(opts) + _progress(opts, f"writing PNG preview {opts.preview_path.name} ({_fmt(opts.preview_pitch_mm)}mm/px, {ow}x{oh}px)...") + t0 = time.time() + render_preview(paths, opts, opts.preview_path) + _progress(opts, f" wrote {opts.preview_path.name} ({time.time() - t0:.1f}s)") + _progress(opts, f"writing SVG preview {out_svg.name}...") + t1 = time.time() + render_preview_svg(paths, opts, out_svg) + _progress(opts, f" wrote {out_svg.name} ({time.time() - t1:.1f}s)") + _progress(opts, f"emitting g-code for {len(paths)} toolpaths...") + gcode = emit_gcode(paths, opts) + _progress(opts, f"done in {time.time() - t_start:.1f}s") + return gcode + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + src = p.add_mutually_exclusive_group(required=True) + src.add_argument("--svg", type=Path, help="Input SVG (rendered via Inkscape)") + src.add_argument("--png", type=Path, help="Pre-rendered black-on-white mask PNG") + p.add_argument("--out", type=Path, required=True, help="Output .gcode path") + p.add_argument("--page-w", type=float, default=300.0, help="Page width mm (default 300)") + p.add_argument("--page-h", type=float, default=300.0, help="Page height mm (default 300)") + p.add_argument("--pitch", type=float, default=0.05, + help="Raster pitch mm/px for the fills medial-axis pass (default 0.05; " + "finer keeps straight strokes straight)") + p.add_argument("--threshold", type=int, default=200, help="Ink luminance cutoff 0-255 (default 200)") + p.add_argument("--max-depth", type=float, default=3.0, help="Deepest cut mm (default 3.0)") + p.add_argument("--min-depth", type=float, default=0.0, help="Floor for faint thin lines mm (default 0)") + p.add_argument("--stepdown", type=float, default=0.6, help="Max depth per pass mm (default 0.6)") + p.add_argument("--prune", type=float, default=0.4, help="Drop skeleton spurs shorter than mm (default 0.4)") + p.add_argument("--chord-tol", type=float, default=0.05, help="Toolpath decimation tol mm (default 0.05)") + p.add_argument("--medial-step", type=float, default=0.15, help="Boundary sampling step for the vector fill medial mm (default 0.15)") + p.add_argument("--fill-arcs", action="store_true", help="Fit fill medial-axis runs to circular/helical arcs (default off)") + p.add_argument("--fill-arc-tol", type=float, default=0.06, help="Arc-fit tolerance for fills mm (default 0.06)") + p.add_argument("--x0", type=float, default=0.0, help="Page origin X on bed mm (default 0)") + p.add_argument("--y0", type=float, default=0.0, help="Page origin Y on bed mm (default 0)") + p.add_argument("--half-angle", type=float, default=None, help="V-bit half-angle deg (default 30, or from --tool)") + p.add_argument("--feed", type=float, default=None, help="Cutting feed mm/min (default 400, or from --tool)") + p.add_argument("--plunge-feed", type=float, default=None, help="Plunge feed mm/min (default 80, or from --tool)") + p.add_argument("--z-safe", type=float, default=2.0, help="Rapid/clearance Z mm (default 2.0)") + p.add_argument("--spindle-rpm", type=int, default=None, help="Spindle RPM (default 14000, or from --tool)") + p.add_argument("--return-home", action="store_true", help="Append G00 X0 Y0 at end") + p.add_argument("--workspace", default=None, choices=["G54", "G55", "G56", "G57", "G58", "G59"], help="Workspace offset to select") + p.add_argument("--stroke-flatten", type=float, default=0.3, help="Curve sampling step for stroke centrelines mm (default 0.3)") + p.add_argument("--no-arcs", action="store_true", help="Do not fit circular stroke runs to G02/G03 arcs") + p.add_argument("--arc-tol", type=float, default=0.02, help="Max deviation when fitting a stroke arc mm (default 0.02)") + p.add_argument("--no-markers", action="store_true", help="Do not carve marker-start/-end arrowheads") + p.add_argument("--preview", nargs="?", const="__AUTO__", default=None, + help="Also write a depth-coloured toolpath preview PNG (defaults to .png)") + p.add_argument("--preview-pitch", type=float, default=0.25, + help="Preview PNG resolution mm/px (default 0.25; smaller = larger image)") + p.add_argument("--quiet", action="store_true", help="Suppress progress output") + p.add_argument("--tool", default=None, help="Tool id/name; supplies half-angle, feeds, RPM defaults") + args = p.parse_args(argv) + + tool = None + if args.tool is not None: + try: + tool = _tools.find(_tools.load(), args.tool) + except (KeyError, ValueError) as e: + p.error(str(e)) + if args.half_angle is None and tool is not None and tool.half_angle_deg is None: + p.error(f"tool {tool.id!r} has no half_angle_deg; pass --half-angle") + + opts = VCarveOpts( + page_w_mm=args.page_w, + page_h_mm=args.page_h, + pitch_mm=args.pitch, + half_angle_deg=_resolve(args.half_angle, tool, "half_angle_deg", 30.0), + threshold=args.threshold, + max_depth_mm=args.max_depth, + min_depth_mm=args.min_depth, + stepdown_mm=args.stepdown, + prune_mm=args.prune, + chord_tol_mm=args.chord_tol, + x0=args.x0, + y0=args.y0, + feed=_resolve(args.feed, tool, "feed_mm_min", 400.0), + plunge_feed=_resolve(args.plunge_feed, tool, "plunge_feed_mm_min", 80.0), + z_safe=args.z_safe, + spindle_rpm=_resolve(args.spindle_rpm, tool, "spindle_rpm", 14000), + return_home=args.return_home, + workspace=args.workspace, + stroke_flatten_mm=args.stroke_flatten, + markers=not args.no_markers, + arcs=not args.no_arcs, + arc_tol_mm=args.arc_tol, + fill_arcs=args.fill_arcs, + fill_arc_tol_mm=args.fill_arc_tol, + medial_step_mm=args.medial_step, + preview_path=( + None + if args.preview is None + else (args.out.with_suffix(".png") if args.preview == "__AUTO__" else Path(args.preview)) + ), + preview_pitch_mm=args.preview_pitch, + bit_diameter_mm=(tool.diameter_mm if tool is not None else None), + progress=not args.quiet, + ) + if args.pitch <= 0: + p.error("--pitch must be > 0") + if args.stepdown <= 0: + p.error("--stepdown must be > 0") + + gcode = run(args.png or args.svg, opts, is_png=args.png is not None) + args.out.write_text(gcode) + print(f"wrote {args.out} ({len(gcode.splitlines())} lines)") + if opts.preview_path is not None: + print(f"wrote preview {opts.preview_path} and {opts.preview_path.with_suffix('.svg')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_maze.py b/tests/test_maze.py index 409044b..351d5a3 100644 --- a/tests/test_maze.py +++ b/tests/test_maze.py @@ -1,4 +1,5 @@ """Tests for rollingmill.maze — erbsland-maze-backed g-code emit.""" + from __future__ import annotations from pathlib import Path @@ -45,6 +46,7 @@ def _opts(**overrides) -> maze.MazeOpts: # ---------- header / footer order ---------- + def test_emit_header_and_footer_order() -> None: out = maze.emit_gcode(_opts(spindle_rpm=8000)) lines = out.splitlines() @@ -83,6 +85,7 @@ def test_emit_return_home_appended() -> None: # ---------- determinism ---------- + def test_same_seed_same_output() -> None: a = maze.emit_gcode(_opts(seed=123)) b = maze.emit_gcode(_opts(seed=123)) @@ -97,6 +100,7 @@ def test_different_seed_different_output() -> None: # ---------- geometry: bounds + Y-flip ---------- + def test_xy_within_canvas_bounds() -> None: opts = _opts(width=50.0, height=30.0, x0=10.0, y0=5.0) out = maze.emit_gcode(opts) @@ -118,21 +122,20 @@ def test_y_axis_is_flipped() -> None: ys = _ys(out) assert max(ys) > height - 0.5 assert min(ys) < 0.5 - first_rapid = next(l for l in out.splitlines() - if l.startswith("G00 X") and "Y" in l) + first_rapid = next(l for l in out.splitlines() if l.startswith("G00 X") and "Y" in l) y = float(next(t for t in first_rapid.split() if t.startswith("Y"))[1:]) assert y > height * 0.7 # ~top of the bed after flip # ---------- single pen cycle structure ---------- + def test_each_polyline_has_pen_down_up() -> None: out = maze.emit_gcode(_opts()) lines = out.splitlines() plunges = [l for l in lines if l.startswith("G01 Z")] lifts = [l for l in lines if l.startswith("G00 Z")] - rapids_xy = [l for l in lines if l.startswith("G00 X") and "Y" in l - and l != "G00 X0.0 Y0.0"] + rapids_xy = [l for l in lines if l.startswith("G00 X") and "Y" in l and l != "G00 X0.0 Y0.0"] # Every drawn polyline has one G00 X Y rapid + one Z plunge. assert len(plunges) == len(rapids_xy) # Lifts include the initial header lift, one per polyline, and the @@ -142,14 +145,14 @@ def test_each_polyline_has_pen_down_up() -> None: def test_nontrivial_stroke_count() -> None: out = maze.emit_gcode(_opts(width=100.0, height=100.0)) - g01_xy = [l for l in out.splitlines() - if l.startswith("G01 X") and "Y" in l] + g01_xy = [l for l in out.splitlines() if l.startswith("G01 X") and "Y" in l] # A 100x100 / 4 mm cell maze has hundreds of wall segments. assert len(g01_xy) > 100 # ---------- endpoint marker modes ---------- + def test_endpoints_none_skips_markers() -> None: none_out = maze.emit_gcode(_opts(endpoints="none")) outline_out = maze.emit_gcode(_opts(endpoints="outline")) @@ -171,6 +174,7 @@ def test_emit_rejects_unknown_endpoint_mode() -> None: # ---------- validation ---------- + def test_emit_rejects_non_positive_dims() -> None: with pytest.raises(ValueError): maze.emit_gcode(_opts(width=0.0)) @@ -187,12 +191,21 @@ def test_emit_rejects_non_positive_cell_wall() -> None: # ---------- CLI ---------- + def test_cli_smoke(tmp_path: Path) -> None: out_path = tmp_path / "m.gcode" - rc = maze.main([ - "--out", str(out_path), "--width", "40", "--height", "40", - "--seed", "42", - ]) + rc = maze.main( + [ + "--out", + str(out_path), + "--width", + "40", + "--height", + "40", + "--seed", + "42", + ] + ) assert rc == 0 text = out_path.read_text() assert "M30" in text @@ -202,21 +215,38 @@ def test_cli_smoke(tmp_path: Path) -> None: def test_cli_seed_in_header(tmp_path: Path) -> None: out_path = tmp_path / "m.gcode" - rc = maze.main([ - "--out", str(out_path), "--width", "30", "--height", "30", - "--seed", "7", - ]) + rc = maze.main( + [ + "--out", + str(out_path), + "--width", + "30", + "--height", + "30", + "--seed", + "7", + ] + ) assert rc == 0 assert "(Seed: 7" in out_path.read_text() -def test_cli_workspace_round_trip(tmp_path: Path, - capsys: pytest.CaptureFixture[str]) -> None: +def test_cli_workspace_round_trip(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: out_path = tmp_path / "m.gcode" - rc = maze.main([ - "--out", str(out_path), "--width", "30", "--height", "30", - "--seed", "5", "--workspace", "G55", - ]) + rc = maze.main( + [ + "--out", + str(out_path), + "--width", + "30", + "--height", + "30", + "--seed", + "5", + "--workspace", + "G55", + ] + ) assert rc == 0 assert "G55" in out_path.read_text().splitlines() captured = capsys.readouterr().out diff --git a/tests/test_pocket.py b/tests/test_pocket.py new file mode 100644 index 0000000..01a9d60 --- /dev/null +++ b/tests/test_pocket.py @@ -0,0 +1,154 @@ +"""Tests for rollingmill/pocket.py -- spiral-out roughing pocket g-code emit.""" + +from __future__ import annotations + +import math +import re + +import pytest + +from rollingmill import pocket + + +def _words(line: str) -> dict[str, float]: + return {m.group(1): float(m.group(2)) for m in re.finditer(r"([A-Z])(-?\d+\.?\d*)", line)} + + +def _trace(gcode: str, step: float = 0.1): + """Walk the cutting moves; yield (z, x, y) samples along every G01/G02/G03.""" + x = y = z = 0.0 + pts = [] + for line in gcode.splitlines(): + if line.startswith("("): + continue + wd = _words(line) + g = wd.get("G") + if g is None and not any(k in wd for k in "XYZ"): + continue + nx, ny, nz = wd.get("X", x), wd.get("Y", y), wd.get("Z", z) + if line.startswith(("G02", "G03")): + xc, yc = x + wd["I"], y + wd["J"] + r0 = math.hypot(x - xc, y - yc) + r1 = math.hypot(nx - xc, ny - yc) + assert r0 == pytest.approx(r1, abs=2e-3), line + a0, a1 = math.atan2(y - yc, x - xc), math.atan2(ny - yc, nx - xc) + d = a1 - a0 + if line.startswith("G03"): + d %= 2 * math.pi + else: + d = -((-d) % (2 * math.pi)) + n = max(1, int(abs(d) * r0 / step)) + for k in range(1, n + 1): + a = a0 + d * k / n + pts.append((nz, xc + r0 * math.cos(a), yc + r0 * math.sin(a))) + elif line.startswith("G01"): + n = max(1, int(math.hypot(nx - x, ny - y) / step)) + for k in range(1, n + 1): + pts.append((nz, x + (nx - x) * k / n, y + (ny - y) * k / n)) + x, y, z = nx, ny, nz + return pts + + +def _opts(**kw) -> pocket.PocketOpts: + base = dict(width_mm=30.0, height_mm=20.0, depth_mm=1.0, tool_dia_mm=3.0, stepover=0.4) + base.update(kw) + return pocket.PocketOpts(**base) + + +def test_levels_and_final_depth(): + g = pocket.emit_gcode(_opts(depth_mm=1.2, z_step_mm=0.5)) + # Entry is helical: G01 Z only drops to the previous floor (in air) ... + assert [float(m) for m in re.findall(r"G01 Z(-?[\d.]+)", g)] == [0.0, -0.5, -1.0] + # ... and each level's helix arcs carry Z down to the new floor. + arc_zs = [float(m) for m in re.findall(r"G0[23] X\S+ Y\S+ Z(-?[\d.]+)", g)] + assert arc_zs == [-0.25, -0.5, -0.75, -1.0, -1.1, -1.2] + assert g.rstrip().endswith("M30") + + +@pytest.mark.parametrize( + "w,h,center,conv", + [(30, 20, False, False), (20, 30, False, False), (25, 25, True, True), (12, 40, True, False)], +) +def test_stays_in_bounds_and_clears_everything(w, h, center, conv): + tool = 3.0 + g = pocket.emit_gcode( + _opts(width_mm=w, height_mm=h, tool_dia_mm=tool, center_origin=center, conventional=conv) + ) + pts = [(x, y) for z, x, y in _trace(g) if z == -1.0] + lo_x, lo_y = (-w / 2, -h / 2) if center else (0.0, 0.0) + r = tool / 2 + for x, y in pts: + assert lo_x + r - 1e-3 <= x <= lo_x + w - r + 1e-3 + assert lo_y + r - 1e-3 <= y <= lo_y + h - r + 1e-3 + # Every point of the pocket (bar the tool-radius corner fillets) is swept. + n = 40 + for i in range(n + 1): + for j in range(n + 1): + px, py = lo_x + w * i / n, lo_y + h * j / n + ex = max(lo_x + r - px, 0, px - (lo_x + w - r)) + ey = max(lo_y + r - py, 0, py - (lo_y + h - r)) + if math.hypot(ex, ey) > r: + continue # in the unreachable corner + assert min(math.hypot(px - x, py - y) for x, y in pts) <= r + 1e-3, (px, py) + + +def test_climb_is_clockwise_and_conventional_reverses(): + assert "G03" not in pocket.emit_gcode(_opts()) + assert "G02" in pocket.emit_gcode(_opts()) + conv = pocket.emit_gcode(_opts(conventional=True)) + assert "G02" not in conv and "G03" in conv + + +def test_helical_entry_is_centred_on_pocket(): + # Helix of r=0.6 (half a 1.2 mm step) round (15, 10). + g = pocket.emit_gcode(_opts()) + assert "G00 X15.0 Y10.6\nG01 Z0.0 F50.0\nF300.0\nG02 X15.0 Y9.4 Z-0.25 I0.0 J-0.6" in g + g = pocket.emit_gcode(_opts(center_origin=True, x0=5.0)) + assert "G00 X5.0 Y0.6\n" in g + + +@pytest.mark.parametrize( + "kw", + [ + dict(width_mm=3.0), + dict(height_mm=2.0), + dict(depth_mm=0), + dict(z_step_mm=0), + dict(stepover=0), + dict(stepover=1.5), + dict(tool_dia_mm=0), + ], +) +def test_rejects_bad_options(kw): + with pytest.raises(ValueError): + pocket.emit_gcode(_opts(**kw)) + + +def test_step_out_keeps_moving_along_long_axis(): + # Closing turn 1 returns to (30, 10.6); the step out to turn 2 (Y11.8) must + # be a slanted line that also advances in X, not a pure sideways jog. + g = pocket.emit_gcode(_opts(width_mm=60.0, height_mm=20.0, depth_mm=0.5)).splitlines() + i = g.index("G01 X30.0 Y10.6") + step = _words(g[i + 1]) + assert g[i + 1].startswith("G01") and step["Y"] == pytest.approx(11.8) + assert step["X"] > 30.0 + + +def test_rows_evenly_spaced_from_the_centre(): + # Rows either side of the axis are one step apart, like every other pair -- + # not two steps, which left a gap down the middle when the cutter was small. + assert pocket._radii(37.5, 3.0)[:3] == [1.5, 4.5, 7.5] + assert pocket._radii(37.5, 3.0)[-1] == 37.5 + # A turn within a quarter step of the last is merged into it. + assert pocket._radii(8.0, 3.0) == [1.5, 4.5, 8.0] + assert pocket._radii(1.0, 3.0) == [1.0] + + +def test_header_records_tool_values(): + g = pocket.emit_gcode(_opts(tool_label="end_5mm - 5mm flat", feed=400.0, spindle_rpm=12000)) + assert ( + "(tool end_5mm - 5mm flat: dia 3 mm, radius 1.5 mm, feed 400, plunge 50 mm/min, S12000)" + in g + ) + assert "stepover: 1.2 mm = 40% of tool" in g + assert "(tool (no library entry): dia 3 mm" in pocket.emit_gcode(_opts()) diff --git a/tests/test_relief3D.py b/tests/test_relief3D.py new file mode 100644 index 0000000..a93a5e3 --- /dev/null +++ b/tests/test_relief3D.py @@ -0,0 +1,264 @@ +"""Tests for rollingmill.relief3D — greyscale-image → 3D relief g-code.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +# All relief3D tests need the optional 'relief' extra. +np = pytest.importorskip("numpy") +PIL = pytest.importorskip("PIL") +pytest.importorskip("scipy") + +from PIL import Image # noqa: E402 + +from rollingmill import relief3D, tools # noqa: E402 + + +@pytest.fixture(autouse=True) +def _isolated_tools_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Point ~/.rollingmill/tools.json at a per-test tmp dir.""" + monkeypatch.setenv("HOME", str(tmp_path)) + # Seed the library; relief3D.emit_gcode() will tools.load() it. + tools.save(tools.SEED_TOOLS) + return tmp_path + + +def _write_image(path: Path, arr_uint8) -> Path: + Image.fromarray(arr_uint8, mode="L").save(path) + return path + + +def _flat_image(path: Path, value: int, size: int = 16) -> Path: + return _write_image(path, np.full((size, size), value, dtype=np.uint8)) + + +def _opts(tmp_path: Path, image: Path, **overrides) -> relief3D.ReliefOpts: + base = dict( + image=image, + out=tmp_path / "out.gcode", + tool="ball_3mm", + stock_width_mm=10.0, + stock_height_mm=10.0, + z_max=0.0, + z_min=-2.0, + pixel_pitch_mm=0.5, + stepover_mm=1.0, + blur_px=0.0, # off for deterministic depths + ) + base.update(overrides) + return relief3D.ReliefOpts(**base) + + +def _extract_z(gcode: str) -> list[float]: + out: list[float] = [] + for tok in re.findall(r"Z(-?\d+(?:\.\d+)?)", gcode): + out.append(float(tok)) + return out + + +# ---------- heightmap basics ---------- + + +def test_load_heightmap_white_maps_to_zmax(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "white.png", 255) + opts = _opts(tmp_path, img) + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + assert h.shape == (20, 20) + assert np.allclose(h, opts.z_max) + + +def test_load_heightmap_black_maps_to_zmin(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "black.png", 0) + opts = _opts(tmp_path, img) + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + assert np.allclose(h, opts.z_min) + + +def test_invert_flips_mapping(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "white.png", 255) + opts = _opts(tmp_path, img, invert=True) + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + assert np.allclose(h, opts.z_min) + + +# ---------- erosion ---------- + + +def test_erosion_on_flat_white_is_flat(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "white.png", 255) + opts = _opts(tmp_path, img) + tool = tools.find(tools.SEED_TOOLS, "ball_3mm") + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + r = relief3D.erode_by_tool(h, tool, pitch_mm=0.5) + assert np.allclose(r, opts.z_max) + + +def test_erosion_widens_dark_region_by_tool(tmp_path: Path) -> None: + """A wide-enough black square lets the ball nose reach z_min at its + centre; far outside the dark region the reach stays at z_max.""" + img_arr = np.full((20, 20), 255, dtype=np.uint8) + # 5x5 mm dark square at centre (pitch=0.5 → 10x10 cells); a 3mm + # ball nose (radius 1.5mm) easily fits with room to spare. + img_arr[5:15, 5:15] = 0 + img = _write_image(tmp_path / "square.png", img_arr) + opts = _opts(tmp_path, img) + tool = tools.find(tools.SEED_TOOLS, "ball_3mm") + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + r = relief3D.erode_by_tool(h, tool, pitch_mm=0.5) + # Deepest reach == z_min (somewhere in the dark core). + assert pytest.approx(r.min(), abs=1e-6) == opts.z_min + # Far away from the dark region the surface is still uncut. + assert pytest.approx(r[0, 0], abs=1e-6) == opts.z_max + + +def test_erosion_in_narrow_pocket_is_limited_by_tool_radius(tmp_path: Path) -> None: + """A pocket too narrow for the tool clamps the reach above z_min.""" + img_arr = np.full((20, 20), 255, dtype=np.uint8) + img_arr[8:12, 8:12] = 0 # 2x2 mm dark square — narrower than 3mm bit + img = _write_image(tmp_path / "narrow.png", img_arr) + opts = _opts(tmp_path, img) + tool = tools.find(tools.SEED_TOOLS, "ball_3mm") + h = relief3D.load_heightmap(opts, pitch_mm=0.5) + r = relief3D.erode_by_tool(h, tool, pitch_mm=0.5) + # Cannot reach z_min: dome of the ball nose hits the white shoulders. + assert r.min() > opts.z_min + + +# ---------- raster path ---------- + + +def test_raster_zigzag_alternates_direction(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 128) + opts = _opts( + tmp_path, img, stepover_mm=2.0, stock_width_mm=5.0, stock_height_mm=5.0, pixel_pitch_mm=1.0 + ) + h = relief3D.load_heightmap(opts, pitch_mm=1.0) + poly = relief3D.raster_path(h, opts, pitch_mm=1.0, stepover_mm=2.0) + # First row goes 0 → x_max; second row x_max → 0. + xs = [p[0] for p in poly] + nx = h.shape[1] + assert xs[0] == pytest.approx(opts.x0) + assert xs[nx - 1] == pytest.approx(opts.x0 + (nx - 1) * 1.0) + assert xs[nx] == pytest.approx(xs[nx - 1]) # Y step keeps X + assert xs[2 * nx - 1] == pytest.approx(opts.x0) + + +def test_raster_oneway_always_increases_x(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 128) + opts = _opts( + tmp_path, + img, + stepover_mm=2.0, + stock_width_mm=5.0, + stock_height_mm=5.0, + pixel_pitch_mm=1.0, + direction="oneway", + ) + h = relief3D.load_heightmap(opts, pitch_mm=1.0) + poly = relief3D.raster_path(h, opts, pitch_mm=1.0, stepover_mm=2.0) + nx = h.shape[1] + # Each row begins at x0. + for row in range(0, len(poly), nx): + assert poly[row][0] == pytest.approx(opts.x0) + + +# ---------- header / footer / flags in g-code ---------- + + +def test_emit_header_and_footer(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 200) + out = relief3D.emit_gcode(_opts(tmp_path, img)) + lines = out.splitlines() + for token in ("G21", "G90", "G17", "M03", "M05", "M30"): + assert token in lines, f"missing {token} in:\n{out}" + assert lines[-2] == "M05" + assert lines[-1] == "M30" + + +def test_workspace_present_in_header(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 200) + out = relief3D.emit_gcode(_opts(tmp_path, img, workspace="G55")) + assert "G55" in out.splitlines() + + +def test_return_home_appends_before_m05(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 200) + out = relief3D.emit_gcode(_opts(tmp_path, img, return_home=True)) + lines = out.splitlines() + idx_m05 = lines.index("M05") + assert lines[idx_m05 - 1] == "G00 X0.0 Y0.0" + + +def test_white_image_emits_zsafe_only(tmp_path: Path) -> None: + """A pure-white image carves nothing below z_max=0; all Zs should be + either the rapid z_safe or the surface 0.0.""" + img = _flat_image(tmp_path / "white.png", 255) + out = relief3D.emit_gcode(_opts(tmp_path, img)) + zs = _extract_z(out) + assert zs, "expected at least one Z word" + # Every Z is 0 (surface) or z_safe (rapid). + for z in zs: + assert z in (0.0, 2.0), f"unexpected Z={z}" + + +def test_dark_image_descends_to_zmin(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "black.png", 0) + out = relief3D.emit_gcode(_opts(tmp_path, img, z_min=-1.5)) + zs = _extract_z(out) + assert min(zs) == pytest.approx(-1.5) + + +# ---------- error paths ---------- + + +def test_unknown_tool_raises_friendly_error(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 128) + opts = _opts(tmp_path, img, tool="no_such_tool") + with pytest.raises(KeyError, match="known:"): + relief3D.emit_gcode(opts) + + +def test_missing_image_raises(tmp_path: Path) -> None: + opts = _opts(tmp_path, tmp_path / "nope.png") + with pytest.raises(FileNotFoundError): + relief3D.emit_gcode(opts) + + +def test_invalid_z_range(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 128) + opts = _opts(tmp_path, img, z_max=-1.0, z_min=0.0) + with pytest.raises(ValueError, match="z_max must be > z_min"): + relief3D.emit_gcode(opts) + + +# ---------- CLI smoke ---------- + + +def test_cli_writes_file(tmp_path: Path) -> None: + img = _flat_image(tmp_path / "g.png", 200) + out = tmp_path / "out.gcode" + rc = relief3D.main( + [ + "--image", + str(img), + "--out", + str(out), + "--tool", + "ball_3mm", + "--stock-width", + "10", + "--stock-height", + "10", + "--z-min", + "-1.0", + "--workspace", + "G54", + ] + ) + assert rc == 0 + text = out.read_text() + assert "G54" in text.splitlines() + assert "M30" in text.splitlines() diff --git a/tests/test_spiral.py b/tests/test_spiral.py index 8184167..9f685f6 100644 --- a/tests/test_spiral.py +++ b/tests/test_spiral.py @@ -1,4 +1,5 @@ """Tests for rollingmill/spiral.py -- neat-spiral (hypotrochoid) g-code emit.""" + from __future__ import annotations import math @@ -11,6 +12,7 @@ # ---------- helpers ---------- + def _xs(s: str) -> list[float]: xs: list[float] = [] for line in s.splitlines(): @@ -37,6 +39,7 @@ def _ys(s: str) -> list[float]: # ---------- header / footer order ---------- + def test_emit_header_and_footer_order() -> None: out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0, spindle_rpm=8000)) lines = out.splitlines() @@ -53,9 +56,7 @@ def test_emit_header_and_footer_order() -> None: def test_emit_workspace_in_header() -> None: - out = spiral.emit_gcode( - 96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0, workspace="G54") - ) + out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0, workspace="G54")) lines = out.splitlines() assert "G54" in lines assert lines.index("G54") < lines.index("G00 Z2.0") @@ -68,9 +69,7 @@ def test_emit_no_workspace_by_default() -> None: def test_emit_return_home_appended() -> None: - out = spiral.emit_gcode( - 96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0, return_home=True) - ) + out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0, return_home=True)) lines = out.splitlines() assert "G00 X0.0 Y0.0" in lines assert lines.index("G00 X0.0 Y0.0") > lines.index("M03") @@ -79,6 +78,7 @@ def test_emit_return_home_appended() -> None: # ---------- single pen cycle ---------- + def test_single_pen_down_up_cycle() -> None: out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0)) lines = out.splitlines() @@ -133,8 +133,7 @@ def test_arc_ij_are_incremental() -> None: r_start = math.hypot(cur_x - cx, cur_y - cy) r_end = math.hypot(ex - cx, ey - cy) assert abs(r_start - r_end) < 0.01, ( - f"arc centre inconsistent: r_start={r_start}, r_end={r_end}, " - f"line={line!r}" + f"arc centre inconsistent: r_start={r_start}, r_end={r_end}, line={line!r}" ) cur_x, cur_y = ex, ey checked += 1 @@ -143,12 +142,11 @@ def test_arc_ij_are_incremental() -> None: # ---------- geometry: centre + scale ---------- + def test_pattern_centred_and_bounded() -> None: radius = 10.0 cx, cy = 50.0, 25.0 - out = spiral.emit_gcode( - 96, 52, 30.0, spiral.SpiralOpts(radius_mm=radius, x0=cx, y0=cy) - ) + out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=radius, x0=cx, y0=cy)) xs = _xs(out) ys = _ys(out) # Skip the trailing return-home zeros (not enabled here, but be defensive). @@ -158,22 +156,17 @@ def test_pattern_centred_and_bounded() -> None: assert all(cx - radius - eps <= x <= cx + radius + eps for x in drawing_xs) assert all(cy - radius - eps <= y <= cy + radius + eps for y in drawing_ys) # And the pattern actually reaches close to the radius envelope. - rmax = max( - math.hypot(x - cx, y - cy) - for x, y in zip(drawing_xs, drawing_ys) - ) + rmax = max(math.hypot(x - cx, y - cy) for x, y in zip(drawing_xs, drawing_ys)) assert abs(rmax - radius) < 0.5 # within sampling tolerance # ---------- closure ---------- + def test_curve_closes_to_start() -> None: out = spiral.emit_gcode(96, 52, 30.0, spiral.SpiralOpts(radius_mm=40.0)) rapids = [l for l in out.splitlines() if l.startswith("G00 X")] - drawn = [ - l for l in out.splitlines() - if l.startswith(("G01 X", "G02 ", "G03 ")) - ] + drawn = [l for l in out.splitlines() if l.startswith(("G01 X", "G02 ", "G03 "))] # First XY is the rapid to start; last drawn move closes the curve. first = rapids[0] last = drawn[-1] @@ -195,6 +188,7 @@ def xy(s: str) -> tuple[float, float]: # ---------- geometry sanity: hypocycloid limit ---------- + def test_hypocycloid_max_extent_equals_radius() -> None: """When d == r the curve is a hypocycloid whose cusps touch a circle of radius (R - r) + d = R - r + r = R (in tooth units). After scaling, max @@ -208,12 +202,23 @@ def test_hypocycloid_max_extent_equals_radius() -> None: # ---------- CLI ---------- + def test_cli_smoke(tmp_path: Path) -> None: out_path = tmp_path / "s.gcode" - rc = spiral.main([ - "--ring", "96", "--rotor", "52", "--pen", "30", - "--radius", "40", "--out", str(out_path), - ]) + rc = spiral.main( + [ + "--ring", + "96", + "--rotor", + "52", + "--pen", + "30", + "--radius", + "40", + "--out", + str(out_path), + ] + ) assert rc == 0 text = out_path.read_text() assert "M30" in text @@ -222,12 +227,26 @@ def test_cli_smoke(tmp_path: Path) -> None: def test_cli_workspace_and_centre(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: out_path = tmp_path / "s.gcode" - rc = spiral.main([ - "--ring", "96", "--rotor", "52", "--pen", "30", - "--radius", "15", "--x0", "100", "--y0", "100", - "--workspace", "G54", - "--out", str(out_path), - ]) + rc = spiral.main( + [ + "--ring", + "96", + "--rotor", + "52", + "--pen", + "30", + "--radius", + "15", + "--x0", + "100", + "--y0", + "100", + "--workspace", + "G54", + "--out", + str(out_path), + ] + ) assert rc == 0 text = out_path.read_text() assert "G54" in text.splitlines() @@ -237,6 +256,7 @@ def test_cli_workspace_and_centre(tmp_path: Path, capsys: pytest.CaptureFixture[ # ---------- validation ---------- + def test_emit_rejects_rotor_ge_ring() -> None: with pytest.raises(ValueError): spiral.emit_gcode(30, 30, 10.0, spiral.SpiralOpts(radius_mm=20.0)) @@ -251,8 +271,18 @@ def test_emit_rejects_non_positive_radius() -> None: def test_cli_rejects_rotor_ge_ring(tmp_path: Path) -> None: with pytest.raises(SystemExit) as ei: - spiral.main([ - "--ring", "30", "--rotor", "30", "--pen", "10", - "--radius", "20", "--out", str(tmp_path / "x.gcode"), - ]) + spiral.main( + [ + "--ring", + "30", + "--rotor", + "30", + "--pen", + "10", + "--radius", + "20", + "--out", + str(tmp_path / "x.gcode"), + ] + ) assert ei.value.code != 0 diff --git a/tests/test_text_to_gcode.py b/tests/test_text.py similarity index 90% rename from tests/test_text_to_gcode.py rename to tests/test_text.py index 18bd763..caefc87 100644 --- a/tests/test_text_to_gcode.py +++ b/tests/test_text.py @@ -1,4 +1,5 @@ -"""Tests for rollingmill.text_to_gcode — pyhershey-backed g-code emit.""" +"""Tests for rollingmill.text — pyhershey-backed g-code emit.""" + from __future__ import annotations import sys @@ -13,11 +14,12 @@ allow_module_level=True, ) -from rollingmill import text_to_gcode as t2g # noqa: E402 +from rollingmill import text as t2g # noqa: E402 # ---------- font discovery ---------- + def test_available_fonts_includes_roman_simplex() -> None: fonts = t2g.available_fonts() assert "roman_simplex" in fonts @@ -30,6 +32,7 @@ def test_default_font_is_in_available_list() -> None: # ---------- emit ---------- + def test_emit_basic_renders_strokes() -> None: out = t2g.emit_gcode("I", "roman_simplex", t2g.GCodeOpts(height_mm=9.0)) lines = out.strip().splitlines() @@ -88,8 +91,7 @@ def test_emit_no_workspace_by_default() -> None: def test_emit_return_home_appended() -> None: - out = t2g.emit_gcode("I", "roman_simplex", - t2g.GCodeOpts(height_mm=9.0, return_home=True)) + out = t2g.emit_gcode("I", "roman_simplex", t2g.GCodeOpts(height_mm=9.0, return_home=True)) lines = out.splitlines() assert "G00 X0.0 Y0.0" in lines assert lines.index("G00 X0.0 Y0.0") > lines.index("M03") @@ -101,6 +103,7 @@ def test_emit_uses_per_glyph_advance_width() -> None: a fixed global LetterSpacing. Verify by checking that the start of the second glyph's first stroke is at the expected cursor position.""" from pyhershey import glyph_factory as gf + g_P = gf.from_ascii("P", "roman_simplex") g_a = gf.from_ascii("a", "roman_simplex") cap = t2g._cap_height("roman_simplex") @@ -124,10 +127,12 @@ def test_emit_uses_per_glyph_advance_width() -> None: def test_letter_spacing_adds_gap() -> None: """`letter_spacing_mm` should add a fixed mm gap after each glyph.""" - out_tight = t2g.emit_gcode("II", "roman_simplex", - t2g.GCodeOpts(height_mm=10.0, letter_spacing_mm=0.0)) - out_loose = t2g.emit_gcode("II", "roman_simplex", - t2g.GCodeOpts(height_mm=10.0, letter_spacing_mm=5.0)) + out_tight = t2g.emit_gcode( + "II", "roman_simplex", t2g.GCodeOpts(height_mm=10.0, letter_spacing_mm=0.0) + ) + out_loose = t2g.emit_gcode( + "II", "roman_simplex", t2g.GCodeOpts(height_mm=10.0, letter_spacing_mm=5.0) + ) def max_x(s: str) -> float: xs = [] diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..0842a3c --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,302 @@ +"""Tests for rollingmill.tools — host-side cutting-tool library.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import pytest + +from rollingmill import tools + + +def _ball(**overrides) -> tools.Tool: + base = dict( + id="b", + name="ball", + type="ball_nose", + diameter_mm=3.0, + length_mm=38.0, + flute_length_mm=12.0, + shank_diameter_mm=3.0, + flutes=2, + feed_mm_min=400.0, + plunge_feed_mm_min=80.0, + spindle_rpm=14000, + ) + base.update(overrides) + return tools.Tool(**base) + + +# ---------- default_path / HOME ---------- + + +def test_default_path_honours_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + assert tools.default_path() == tmp_path / ".rollingmill" / "tools.json" + + +# ---------- load / save round-trip ---------- + + +def test_save_then_load_round_trip(tmp_path: Path) -> None: + p = tmp_path / "tools.json" + seed = [ + _ball(), + tools.Tool( + id="vb", + name="vbit", + type="v_bit", + diameter_mm=6.0, + length_mm=38.0, + flute_length_mm=10.0, + shank_diameter_mm=6.0, + flutes=2, + half_angle_deg=30.0, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ), + ] + tools.save(seed, p) + loaded = tools.load(p) + assert [t.id for t in loaded] == ["b", "vb"] + assert loaded[1].half_angle_deg == 30.0 + + +def test_load_missing_seeds_file(tmp_path: Path) -> None: + p = tmp_path / "fresh" / "tools.json" + assert not p.exists() + out = tools.load(p) + assert p.exists() + ids = {t.id for t in out} + assert ids == {t.id for t in tools.SEED_TOOLS} + # The written file should be valid JSON. + raw = json.loads(p.read_text()) + assert isinstance(raw, list) and len(raw) == len(tools.SEED_TOOLS) + + +def test_load_rejects_non_list(tmp_path: Path) -> None: + p = tmp_path / "tools.json" + p.write_text('{"id": "x"}') + with pytest.raises(ValueError, match="expected a JSON list"): + tools.load(p) + + +def test_to_dict_omits_none_fields() -> None: + t = _ball() + d = t.to_dict() + assert "half_angle_deg" not in d + assert "corner_radius_mm" not in d + assert "firmware_slot" not in d + + +# ---------- find ---------- + + +def test_find_by_id() -> None: + seed = tools.SEED_TOOLS + assert tools.find(seed, "ball_3mm").id == "ball_3mm" + + +def test_find_by_name_fallback() -> None: + seed = tools.SEED_TOOLS + assert tools.find(seed, "3mm 2-flute ball nose").id == "ball_3mm" + + +def test_find_missing_raises_with_known_list() -> None: + seed = tools.SEED_TOOLS + with pytest.raises(KeyError, match="known:"): + tools.find(seed, "no_such_tool") + + +# ---------- add / remove ---------- + + +def test_add_rejects_duplicate_id() -> None: + seed = list(tools.SEED_TOOLS) + with pytest.raises(ValueError, match="already exists"): + tools.add(seed, _ball(id="ball_3mm")) + + +def test_remove_returns_smaller_list() -> None: + seed = list(tools.SEED_TOOLS) + out = tools.remove(seed, "ball_3mm") + assert len(out) == len(seed) - 1 + assert all(t.id != "ball_3mm" for t in out) + + +def test_remove_missing_raises() -> None: + with pytest.raises(KeyError): + tools.remove(list(tools.SEED_TOOLS), "no_such") + + +# ---------- validation ---------- + + +def test_vbit_requires_half_angle() -> None: + with pytest.raises(ValueError, match="v_bit requires half_angle_deg"): + tools.Tool( + id="v", + name="v", + type="v_bit", + diameter_mm=6.0, + length_mm=38, + flute_length_mm=10, + shank_diameter_mm=6.0, + flutes=2, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ) + + +def test_bullnose_requires_corner_radius() -> None: + with pytest.raises(ValueError, match="bull_nose requires corner_radius_mm"): + tools.Tool( + id="bn", + name="bn", + type="bull_nose", + diameter_mm=6.0, + length_mm=38, + flute_length_mm=10, + shank_diameter_mm=6.0, + flutes=2, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ) + + +def test_unknown_type_raises() -> None: + with pytest.raises(ValueError, match="type"): + _ball(type="laser") # type: ignore[arg-type] + + +def test_zero_diameter_raises() -> None: + with pytest.raises(ValueError, match="diameter_mm"): + _ball(diameter_mm=0.0) + + +def test_firmware_slot_range() -> None: + with pytest.raises(ValueError, match="firmware_slot"): + _ball(firmware_slot=9) + + +def test_half_angle_range() -> None: + with pytest.raises(ValueError, match="half_angle_deg"): + tools.Tool( + id="v", + name="v", + type="v_bit", + diameter_mm=6.0, + length_mm=38, + flute_length_mm=10, + shank_diameter_mm=6.0, + flutes=2, + half_angle_deg=0.0, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ) + + +# ---------- footprint shapes (depend on numpy) ---------- + + +def test_endmill_footprint_is_flat_disc() -> None: + np = pytest.importorskip("numpy") + t = tools.Tool( + id="e", + name="e", + type="end_mill", + diameter_mm=4.0, + length_mm=30, + flute_length_mm=15, + shank_diameter_mm=4.0, + flutes=2, + feed_mm_min=600, + plunge_feed_mm_min=150, + spindle_rpm=12000, + ) + fp = t.footprint(pitch_mm=0.5) + # Centre: 0; corner: inf + cy = cx = fp.shape[0] // 2 + assert fp[cy, cx] == 0.0 + assert math.isinf(fp[0, 0]) + # Inside-the-disc cells are all zero. + inside = fp != np.inf + assert np.all(fp[inside] == 0.0) + + +def test_ballnose_footprint_dome() -> None: + pytest.importorskip("numpy") + t = _ball() # 3mm dia → r=1.5 + fp = t.footprint(pitch_mm=0.1) + cy = cx = fp.shape[0] // 2 + assert fp[cy, cx] == pytest.approx(0.0) + # At an off-centre pixel inside the disc, offset should equal + # r - sqrt(r^2 - d^2) + r = 1.5 + d = 0.5 + px = int(round(d / 0.1)) + expected = r - math.sqrt(r * r - d * d) + assert fp[cy, cx + px] == pytest.approx(expected, abs=0.02) + # Outside the disc is inf. + assert math.isinf(fp[0, 0]) + + +def test_vbit_footprint_cone() -> None: + pytest.importorskip("numpy") + t = tools.Tool( + id="v", + name="v", + type="v_bit", + diameter_mm=6.0, + length_mm=38, + flute_length_mm=10, + shank_diameter_mm=6.0, + flutes=2, + half_angle_deg=45.0, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ) + fp = t.footprint(pitch_mm=0.5) + cy = cx = fp.shape[0] // 2 + # 45° half-angle → offset == radial distance. + d = 1.0 + px = int(round(d / 0.5)) + assert fp[cy, cx + px] == pytest.approx(d, abs=0.01) + assert fp[cy, cx] == pytest.approx(0.0) + + +def test_bullnose_footprint_flat_centre_round_shoulder() -> None: + pytest.importorskip("numpy") + t = tools.Tool( + id="bn", + name="bn", + type="bull_nose", + diameter_mm=6.0, + length_mm=38, + flute_length_mm=15, + shank_diameter_mm=6.0, + flutes=2, + corner_radius_mm=1.0, + feed_mm_min=500, + plunge_feed_mm_min=80, + spindle_rpm=14000, + ) + fp = t.footprint(pitch_mm=0.1) + cy = cx = fp.shape[0] // 2 + # Centre is flat (within flat radius = 3 - 1 = 2 mm). + assert fp[cy, cx] == 0.0 + # At d ≈ 1.5 mm we're inside the flat region (flat_r=2.0): still 0. + px = int(round(1.5 / 0.1)) + assert fp[cy, cx + px] == pytest.approx(0.0, abs=1e-9) + # At d ≈ 2.5 mm we're in the torus (flat_r=2, c=1). + px = int(round(2.5 / 0.1)) + d_torus = 0.5 # 2.5 - 2.0 + expected = 1.0 - math.sqrt(1.0 - d_torus * d_torus) + assert fp[cy, cx + px] == pytest.approx(expected, abs=0.02) diff --git a/tests/test_totem.py b/tests/test_totem.py new file mode 100644 index 0000000..318300f --- /dev/null +++ b/tests/test_totem.py @@ -0,0 +1,646 @@ +"""Tests for rollingmill.totem -- stacked rotary-axis spindle g-code emit.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import pytest + +from rollingmill import totem + + +def _opts(**overrides) -> totem.TotemOpts: + base = dict(stock_radius=15.0, safe_radius=22.0, depth_step=1.0, angle_step_deg=30.0) + base.update(overrides) + return totem.TotemOpts(**base) + + +def _sec(shape: str, size: float, length: float) -> totem.Section: + return totem.Section(shape=shape, size=size, length=length) + + +# ---------- header / footer order ---------- + + +def test_emit_header_and_footer_order() -> None: + out = totem.emit_gcode([_sec("cylinder", 10.0, 10.0)], _opts(spindle_rpm=8000)) + lines = out.splitlines() + for token in ("G21", "G90", "G17", "S8000", "M03", "M05", "M30"): + assert token in lines, f"missing {token}" + assert lines.index("S8000") < lines.index("M03") + assert lines.index("M03") < lines.index("M05") < lines.index("M30") + + +def test_workspace_emitted_in_header() -> None: + out = totem.emit_gcode([_sec("cylinder", 10.0, 10.0)], _opts(workspace="G55")) + assert "G55" in out.splitlines() + + +def test_return_home_emits_xy_zero_and_a_zero() -> None: + out = totem.emit_gcode([_sec("cylinder", 8.0, 5.0)], _opts(return_home=True)) + lines = out.splitlines() + assert "G00 X0.0 Y0.0" in lines + assert any(l.startswith("G01 A0.0") for l in lines) + + +# ---------- safety: A moves preceded by safe-Z retract ---------- + + +def test_every_a_move_is_preceded_by_z_safe_retract() -> None: + """Every A rotation must be preceded by a Z retract to safe_radius, + so the tool clears the workpiece while it spins. The initial A0 datum + only needs the Z retract; subsequent in-loop A moves should also have + a Y0 reset between the retract and the A move (Y is the off-axis + coordinate now that A rotates around X).""" + safe_r = 22.0 + safe_line = f"G00 Z{safe_r}" + out = totem.emit_gcode([_sec("hex", 12.0, 10.0)], _opts(safe_radius=safe_r)) + lines = out.splitlines() + a_moves = [i for i, l in enumerate(lines) if l.startswith("G01 A")] + assert a_moves, "expected at least one A move" + for i in a_moves: + window = lines[max(0, i - 3) : i] + assert safe_line in window, ( + f"A move at line {i} not preceded by {safe_line!r} within 3 lines (window={window!r})" + ) + + in_loop = [i for i in a_moves if lines[i - 1].startswith("G00 Y")] + assert in_loop, "expected in-loop A moves to be preceded by Y0 reset" + for i in in_loop: + assert lines[i - 1] == "G00 Y0.0", ( + f"A move at line {i} not preceded by Y0 reset: {lines[i - 1]!r}" + ) + assert lines[i - 2] == safe_line, ( + f"A move at line {i} not preceded by Z retract: {lines[i - 2]!r}" + ) + + +# ---------- polygon facet count ---------- + + +def test_polygon_emits_one_face_block_per_side() -> None: + """Hex (6 sides) at angle_step doesn't apply -- polygons step A by n + discrete face positions.""" + out = totem.emit_gcode([_sec("hex", 10.0, 10.0)], _opts()) + a_angles = [float(l.split()[1][1:]) for l in out.splitlines() if l.startswith("G01 A")] + # First A0 is the initial datum; then 6 A-moves for hex faces. + # Last A is the final/return position if return_home; here disabled. + distinct = sorted(set(a_angles)) + # Expect 0, 60, 120, 180, 240, 300 covered. + expected = [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] + for e in expected: + assert any(abs(a - e) < 0.01 for a in distinct), f"missing A={e} in {distinct}" + + +def test_square_emits_four_face_angles() -> None: + out = totem.emit_gcode([_sec("square", 8.0, 6.0)], _opts()) + a_angles = set() + for l in out.splitlines(): + if l.startswith("G01 A"): + a_angles.add(round(float(l.split()[1][1:]), 1)) + for e in (0.0, 90.0, 180.0, 270.0): + assert e in a_angles + + +# ---------- round shape angular step ---------- + + +def test_cylinder_uses_angle_step() -> None: + out = totem.emit_gcode([_sec("cylinder", 10.0, 10.0)], _opts(angle_step_deg=45.0)) + a_angles = set() + for l in out.splitlines(): + if l.startswith("G01 A"): + a_angles.add(round(float(l.split()[1][1:]), 1)) + # 360/45 = 8 steps: 0, 45, 90, 135, 180, 225, 270, 315. + for e in (0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0): + assert e in a_angles + + +# ---------- depth passes ---------- + + +def test_polygon_raster_is_inset_by_tool_radius_in_x() -> None: + """Along the totem length (X), the tool tip must stop one tool radius + short of the section ends so the cutter body just touches the + boundary without overcutting into the neighbouring section. The Y + (face-width) direction deliberately overshoots for corner clearing + and is covered by the per-face extension tests below.""" + size = 10.0 + tool_d = 3.0 + section_length = 12.0 + out = totem.emit_gcode( + [_sec("square", size, section_length)], _opts(tool_diameter=tool_d, depth_step=2.0) + ) + tool_r = tool_d / 2.0 + x_max_expected = section_length - tool_r + + xs: list[float] = [] + for l in out.splitlines(): + if not (l.startswith("G01") or l.startswith("G00 X") or l.startswith("G00 Y")): + continue + for tok in l.split(): + if tok.startswith("X") and len(tok) > 1: + try: + xs.append(float(tok[1:])) + except ValueError: + pass + cut_xs = [x for x in xs if abs(x) > 1e-9] + assert max(cut_xs) == pytest.approx(x_max_expected, abs=0.01) + assert min(cut_xs) == pytest.approx(tool_r, abs=0.01) + + +def test_polygon_cleanup_perimeter_pass_per_z() -> None: + """Each polygon Z pass must end with a closed perimeter trace at the + same Z (4 edges forming the inset rectangle) before the next motion + (either the rapid to the next pass's start Y, or the next face's + safe-Z retract — both are G00s).""" + out = totem.emit_gcode([_sec("hex", 12.0, 10.0)], _opts(depth_step=2.0, tool_diameter=3.0)) + lines = out.splitlines() + plunges = [i for i, l in enumerate(lines) if l.startswith("G01 Z")] + assert len(plunges) > 0 + # For each plunge, verify the segment ending at the next G00 contains + # the 4 perimeter edges as its last lines, in order: X to x_max along + # y_min, Y to y_max along x_max, X to x_min along y_max, Y to y_min + # closing the loop. + side = 2.0 * 12.0 * math.sin(math.pi / 6) + half_side = side / 2.0 + tool_r = 1.5 + # Inset rectangle: X along totem length [0+tool_r, 10-tool_r], + # Y along face width [-half_side+tool_r, +half_side-tool_r]. + x_min = tool_r + x_max = 10.0 - tool_r + y_max = half_side - tool_r + x_min_str = f"G01 X{_fmtp_for_test(x_min)}" + x_max_str = f"G01 X{_fmtp_for_test(x_max)}" + y_min_str = f"G01 Y{_fmtp_for_test(-y_max)}" + y_max_str = f"G01 Y{_fmtp_for_test(y_max)}" + for plunge_idx in plunges: + next_g00 = next(i for i in range(plunge_idx + 1, len(lines)) if lines[i].startswith("G00")) + segment = lines[plunge_idx:next_g00] + assert segment[-4:] == [x_max_str, y_max_str, x_min_str, y_min_str], ( + f"unexpected perimeter at plunge {plunge_idx}: {segment[-5:]!r}" + ) + + +def _fmtp_for_test(v: float) -> str: + """Mirror the formatting in totem._fmtp so test expectations match.""" + return totem._fmtp(v) + + +def _per_face_raster_y_bounds(out: str, n_faces: int) -> list[tuple[float, float]]: + """Slice the g-code into one (min_y, max_y) tuple per face. + + Collects every Y value in the face's body. The corner-clearing + extension is along Y (the face width direction, since the totem is + along X and adjacent polygon faces meet at the Y=±half_side + boundaries). The cleanup trace uses the inset (y_min, y_max) so it + never extends past the raster's range; the observed min/max equals + the raster's actual reach including any extension. + """ + lines = out.splitlines() + a_lines = [i for i, l in enumerate(lines) if l.startswith("G01 A")] + # First "G01 A0.0" is the initial datum; the next n are per-face cuts. + assert len(a_lines) >= n_faces + 1, a_lines + face_starts = a_lines[1 : 1 + n_faces] + face_ends = face_starts[1:] + [len(lines)] + bounds: list[tuple[float, float]] = [] + for start, end in zip(face_starts, face_ends): + ys: list[float] = [] + for line in lines[start:end]: + if not ( + line.startswith("G00 X") or line.startswith("G00 Y") or line.startswith("G01 ") + ): + continue + for tok in line.split(): + if tok.startswith("Y") and len(tok) > 1: + try: + v = float(tok[1:]) + except ValueError: + continue + # Skip the safe-Y-reset (Y0.0) that precedes A moves. + if abs(v) < 1e-9: + continue + ys.append(v) + bounds.append((min(ys), max(ys))) + return bounds + + +def test_face_zero_extends_both_y_directions() -> None: + """Face 0 must overshoot both y_min and y_max by the corner-clearing + extension so the two corners shared with face 1 and face N-1 are + pre-cleared. The extension is Z-aware: at the deepest pass (Z = + apothem) the raster reaches ±(chord_half - tool_r) where chord_half + is the cylindrical stock's half-chord at that Z; shallower passes + extend less.""" + size = 12.0 + tool_d = 3.0 + opts = _opts(tool_diameter=tool_d, depth_step=2.0) + out = totem.emit_gcode([_sec("hex", size, 10.0)], opts) + tool_r = tool_d / 2.0 + apothem = size * math.cos(math.pi / 6) + chord_half = math.sqrt(opts.stock_radius**2 - apothem**2) + y_clear = chord_half - tool_r + bounds = _per_face_raster_y_bounds(out, n_faces=6) + y_min_face0, y_max_face0 = bounds[0] + assert y_max_face0 == pytest.approx(y_clear, abs=0.01) + assert y_min_face0 == pytest.approx(-y_clear, abs=0.01) + + +def test_middle_faces_extend_toward_next_face_only() -> None: + """Faces 1..N-2 extend on +Y (toward the next-to-be-cut face under the + standard RH-rule positive-A rotation, which appears CW from the + operator's tailstock-side view) and stop at the inset y_min on the + -Y side (where the previously-cut face already cleared the corner). + The +Y extension is Z-aware: at the deepest pass it reaches + chord_half - tool_r, where chord_half is the cylindrical stock's + half-chord at Z=apothem.""" + size = 12.0 + tool_d = 3.0 + opts = _opts(tool_diameter=tool_d, depth_step=2.0) + out = totem.emit_gcode([_sec("hex", size, 10.0)], opts) + half_side = size * math.sin(math.pi / 6) + tool_r = tool_d / 2.0 + y_max_inset = half_side - tool_r + apothem = size * math.cos(math.pi / 6) + chord_half = math.sqrt(opts.stock_radius**2 - apothem**2) + y_clear = chord_half - tool_r + bounds = _per_face_raster_y_bounds(out, n_faces=6) + for i in (1, 2, 3, 4): # middle faces (n=6, so 1..N-2 = 1..4) + y_min_face, y_max_face = bounds[i] + assert y_max_face == pytest.approx(y_clear, abs=0.01), ( + f"face {i}: y_max should reach chord_half-tool_r, got {y_max_face}" + ) + assert y_min_face == pytest.approx(-y_max_inset, abs=0.01), ( + f"face {i}: y_min should be inset, got {y_min_face}" + ) + + +def test_last_face_extends_both_y_directions() -> None: + """Face N-1 must overshoot both y_min and y_max like face 0. Face 0's + -Y body and face N-2's +Y body each terminate *tangent* to the + original cylinder boundary at the corners they share with face N-1 + — no chip engagement, leaving a residual ridge. Face N-1 needs its + own chord_half extension on both sides to approach each corner from + the opposite frame and sever the burr.""" + size = 12.0 + tool_d = 3.0 + opts = _opts(tool_diameter=tool_d, depth_step=2.0) + out = totem.emit_gcode([_sec("hex", size, 10.0)], opts) + tool_r = tool_d / 2.0 + apothem = size * math.cos(math.pi / 6) + chord_half = math.sqrt(opts.stock_radius**2 - apothem**2) + y_clear = chord_half - tool_r + bounds = _per_face_raster_y_bounds(out, n_faces=6) + y_min_face_last, y_max_face_last = bounds[5] + assert y_max_face_last == pytest.approx(y_clear, abs=0.01) + assert y_min_face_last == pytest.approx(-y_clear, abs=0.01) + + +def test_polygon_z_passes_terminate_at_apothem() -> None: + """For a hex with size=12, the deepest Z must equal the apothem + (= 12 * cos(pi/6) ~= 10.392).""" + out = totem.emit_gcode([_sec("hex", 12.0, 10.0)], _opts(depth_step=0.5)) + z_cuts = [float(l.split()[1][1:]) for l in out.splitlines() if l.startswith("G01 Z")] + expected_apothem = 12.0 * math.cos(math.pi / 6) + assert min(z_cuts) == pytest.approx(expected_apothem, abs=0.001) + + +def test_cylinder_z_passes_terminate_at_radius() -> None: + out = totem.emit_gcode([_sec("cylinder", 8.0, 10.0)], _opts(depth_step=0.5)) + z_cuts = [float(l.split()[1][1:]) for l in out.splitlines() if l.startswith("G01 Z")] + assert min(z_cuts) == pytest.approx(8.0, abs=0.001) + + +def test_cylinder_one_safe_retract_per_angle() -> None: + """Within one A angle the tool should sweep back-and-forth across Z + passes and only retract to safe_radius once, before the next A + rotation. So between any two consecutive A moves there must be + exactly one G00 Z line, not one per Z pass.""" + safe_r = 22.0 + safe_line = f"G00 Z{_fmtp_for_test(safe_r)}" + # depth_step small enough that multiple Z passes are needed per angle. + out = totem.emit_gcode( + [_sec("cylinder", 8.0, 10.0)], + _opts(safe_radius=safe_r, depth_step=0.5, angle_step_deg=90.0), + ) + lines = out.splitlines() + a_indices = [i for i, l in enumerate(lines) if l.startswith("G01 A")] + assert len(a_indices) >= 3, a_indices + # Look at retract counts between successive in-section A moves + # (skip the initial A0 datum window — it precedes the first cut). + for prev, curr in zip(a_indices[1:-1], a_indices[2:]): + segment = lines[prev:curr] + retract_count = sum(1 for l in segment if l == safe_line) + assert retract_count == 1, ( + f"expected 1 safe-Z retract between A moves at lines " + f"{prev}..{curr}, got {retract_count}: {segment!r}" + ) + + +def test_cylinder_sweep_direction_alternates() -> None: + """Across Z passes at one angle the X target should alternate + between x_end and x_start (back-and-forth), rather than always + cutting toward x_end with a rapid return between passes.""" + out = totem.emit_gcode( + [_sec("cylinder", 8.0, 10.0)], _opts(depth_step=0.5, angle_step_deg=180.0) + ) + lines = out.splitlines() + # Find the first A move after the initial A0 datum and take the + # block up to the next A move (or end). Within it, the G01 X… lines + # are the per-pass cut targets. + a_indices = [i for i, l in enumerate(lines) if l.startswith("G01 A")] + # a_indices[0] is the datum; a_indices[1] is the first per-angle cut. + start = a_indices[1] + end = a_indices[2] if len(a_indices) > 2 else len(lines) + cut_xs: list[float] = [] + for l in lines[start:end]: + if l.startswith("G01 X"): + cut_xs.append(float(l.split()[1][1:])) + assert len(cut_xs) >= 2, f"expected multiple Z passes: {cut_xs!r}" + # Pass 1 cuts toward x_end (=10), pass 2 toward x_start (=0), … + expected = [10.0 if i % 2 == 0 else 0.0 for i in range(len(cut_xs))] + for got, exp in zip(cut_xs, expected): + assert got == pytest.approx(exp, abs=0.001), ( + f"sweep target sequence {cut_xs!r} does not alternate end/start" + ) + + +# ---------- section stacking along X ---------- + + +def test_sections_stack_in_x() -> None: + sections = [ + _sec("cylinder", 10.0, 10.0), + _sec("cylinder", 8.0, 15.0), + ] + out = totem.emit_gcode(sections, _opts(x0=5.0)) + xs: set[float] = set() + for l in out.splitlines(): + for tok in l.split(): + if tok.startswith("X") and len(tok) > 1: + try: + xs.add(round(float(tok[1:]), 3)) + except ValueError: + pass + # First section spans x=[5, 15], second [15, 30]; both endpoints emitted. + assert 5.0 in xs + assert 15.0 in xs + assert 30.0 in xs + + +# ---------- shape aliases ---------- + + +def test_cube_aliases_to_square() -> None: + out = totem.emit_gcode([_sec("cube", 8.0, 6.0)], _opts()) + a_angles = { + round(float(l.split()[1][1:]), 1) for l in out.splitlines() if l.startswith("G01 A") + } + for e in (0.0, 90.0, 180.0, 270.0): + assert e in a_angles + + +def test_torus_aliases_to_donut() -> None: + # Should not raise; just verify the section runs and emits. + out = totem.emit_gcode([_sec("torus", 10.0, 20.0)], _opts(angle_step_deg=90.0)) + assert "M30" in out.splitlines() + + +# ---------- donut: arcs instead of polyline ---------- + + +def test_donut_uses_g18_arcs_not_polyline() -> None: + """Donut sections must emit G02/G03 arcs in the G18 (XZ) plane, not + many tiny G01 X..Z.. polyline segments — the Roland MDX-40A's + block-processing rate can't keep motion fluid through a 40-segment + sine approximation.""" + out = totem.emit_gcode([_sec("donut", 10.0, 20.0)], _opts(angle_step_deg=90.0, depth_step=2.0)) + lines = out.splitlines() + arc_lines = [l for l in lines if l.startswith("G02") or l.startswith("G03")] + polyline_xz = [l for l in lines if l.startswith("G01 X") and " Z" in l] + assert arc_lines, "expected donut to use G02/G03 arcs" + assert not polyline_xz, ( + f"expected no G01 X..Z.. polyline waypoints, found {len(polyline_xz)}: {polyline_xz[:3]!r}" + ) + assert "G18" in lines, "donut must switch to XZ arc plane" + # G17 appears in the header; after the donut section it must be + # restored so subsequent sections (or end-of-program) see XY mode. + last_g18 = max(i for i, l in enumerate(lines) if l == "G18") + next_g17 = next( + (i for i, l in enumerate(lines[last_g18:], start=last_g18) if l == "G17"), + None, + ) + assert next_g17 is not None, "G18 must be balanced by a later G17" + + +def test_donut_arc_geometry_matches_sine_at_three_points() -> None: + """The arc through each pass interpolates the half-sine bulge at three + points: the two crossings (x1, z_pass) and (x2, z_pass) where the + sine equals z_pass, plus the peak (length/2, size). Verify the arc + endpoints and centre offsets land where the math expects.""" + size = 10.0 + length = 20.0 + out = totem.emit_gcode( + [_sec("donut", size, length)], _opts(angle_step_deg=90.0, depth_step=2.0) + ) + lines = out.splitlines() + # Find a representative rising-arc/falling-arc pair: a G02 ending at + # peak (Z=size) followed immediately by a G02 ending at z_pass. + pairs = [] + for i, l in enumerate(lines[:-1]): + if not l.startswith("G02 "): + continue + nxt = lines[i + 1] + if not nxt.startswith("G02 "): + continue + if f"Z{_fmtp_for_test(size)}" not in l: + continue + pairs.append((l, nxt)) + assert pairs, "expected at least one rising+falling G02 arc pair" + rise, fall = pairs[0] + # Falling arc's K offset should be (zc - peak) and its endpoint Z is + # z_pass. Rising arc's endpoint is the peak; its I offset is dx. + # Extract Z from falling arc: + z_pass = float([tok[1:] for tok in fall.split() if tok.startswith("Z")][0]) + base = size / 2 + bulge = size - base + x1 = (length / math.pi) * math.asin((z_pass - base) / bulge) + dx = length / 2 - x1 + zc = ((size * size - z_pass * z_pass) - dx * dx) / (2 * (size - z_pass)) + # Rising-arc tokens: G02 X{mid} Z{peak} I{dx} K{zc - z_pass} + rise_toks = {tok[0]: float(tok[1:]) for tok in rise.split()[1:]} + # G02 sweeps -X from x2 to the midpoint, so the centre is at -dx. + assert rise_toks["I"] == pytest.approx(-dx, abs=0.01) + assert rise_toks["K"] == pytest.approx(zc - z_pass, abs=0.01) + # Falling-arc tokens: G02 X{x_start+x2} Z{z_pass} I0 K{zc - peak} + fall_toks = {tok[0]: float(tok[1:]) for tok in fall.split()[1:]} + assert fall_toks["I"] == pytest.approx(0.0, abs=0.01) + assert fall_toks["K"] == pytest.approx(zc - size, abs=0.01) + + +def test_donut_alternates_g02_g03_with_sweep_direction() -> None: + """Left-to-right sweeps go CW in the G18 frame (G02); right-to-left + sweeps go CCW (G03). Within one theta block, successive arc-using + passes alternate sweep direction and so alternate G02/G03. + (sweep_dir resets to +1 at each new theta, so the alternation only + holds inside a single theta's pass list.)""" + out = totem.emit_gcode([_sec("donut", 10.0, 20.0)], _opts(angle_step_deg=360.0, depth_step=2.0)) + # Single theta -> all rising-to-peak arcs are in one block. + rises = [ + l + for l in out.splitlines() + if (l.startswith("G02") or l.startswith("G03")) and " Z10.0 " in (l + " ") + ] + assert len(rises) >= 2, f"expected multiple arc-using passes: {rises!r}" + codes = [l.split()[0] for l in rises] + for a, b in zip(codes, codes[1:]): + assert a != b, f"expected alternating G02/G03, got {codes!r}" + + +# ---------- validation ---------- + + +def test_rejects_empty_sections() -> None: + with pytest.raises(ValueError): + totem.emit_gcode([], _opts()) + + +def test_rejects_size_exceeding_stock_radius() -> None: + with pytest.raises(ValueError): + totem.emit_gcode([_sec("cylinder", 20.0, 10.0)], _opts(stock_radius=15.0)) + + +def test_rejects_unknown_shape() -> None: + with pytest.raises(ValueError): + totem.emit_gcode([_sec("dodecahedron", 10.0, 10.0)], _opts()) + + +def test_rejects_safe_radius_below_stock() -> None: + with pytest.raises(ValueError): + totem.emit_gcode([_sec("cylinder", 10.0, 10.0)], _opts(stock_radius=15.0, safe_radius=10.0)) + + +def test_rejects_non_positive_dims() -> None: + with pytest.raises(ValueError): + totem.emit_gcode([_sec("cylinder", 0.0, 10.0)], _opts()) + with pytest.raises(ValueError): + totem.emit_gcode([_sec("cylinder", 10.0, 0.0)], _opts()) + + +# ---------- shape radius math ---------- + + +def test_polygon_apothem_at_face_center() -> None: + """For a regular n-gon with circumscribed radius R, r(theta=0) equals the + apothem R*cos(pi/n) (because the face is centred on theta=0).""" + for shape, n in totem.POLYGON_SIDES.items(): + r0 = totem._r_polygon(n, 10.0, 0.0) + assert r0 == pytest.approx(10.0 * math.cos(math.pi / n)) + + +def test_polygon_corner_radius_equals_size() -> None: + """At theta = pi/n the polygon's corner sits at the full circumscribed + radius.""" + for shape, n in totem.POLYGON_SIDES.items(): + r_corner = totem._r_polygon(n, 10.0, math.pi / n) + assert r_corner == pytest.approx(10.0, abs=1e-9) + + +def test_ellipse_axes() -> None: + r0 = totem._r_ellipse(10.0, 0.0, 0.6) + r_quarter = totem._r_ellipse(10.0, math.pi / 2, 0.6) + assert r0 == pytest.approx(10.0) + assert r_quarter == pytest.approx(6.0) + + +# ---------- CLI ---------- + + +def test_cli_smoke(tmp_path: Path) -> None: + out_path = tmp_path / "t.gcode" + rc = totem.main( + [ + "--out", + str(out_path), + "--stock-radius", + "15", + "--safe-radius", + "22", + "--shape", + "cylinder:10:10", + "--shape", + "hex:12:15", + "--angle-step", + "30", + ] + ) + assert rc == 0 + text = out_path.read_text() + assert "M30" in text + assert "(Section: cylinder" in text + assert "(Section: hex" in text + + +def test_cli_requires_at_least_one_shape(tmp_path: Path) -> None: + out_path = tmp_path / "t.gcode" + with pytest.raises(SystemExit): + totem.main(["--out", str(out_path)]) + + +def test_cli_rejects_malformed_shape(tmp_path: Path) -> None: + out_path = tmp_path / "t.gcode" + with pytest.raises(SystemExit): + totem.main( + [ + "--out", + str(out_path), + "--shape", + "cylinder:10", # missing length + ] + ) + + +def test_cli_accepts_semicolon_and_comma_separators(tmp_path: Path) -> None: + out_path = tmp_path / "t.gcode" + rc = totem.main( + [ + "--out", + str(out_path), + "--shape", + "cylinder;10;10", + "--shape", + "hex,12,10", + "--angle-step", + "60", + ] + ) + assert rc == 0 + + +def test_cli_tool_supplies_defaults_and_explicit_flag_overrides( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--tool fills in spindle from the library; --feed still overrides.""" + monkeypatch.setenv("HOME", str(tmp_path)) + out_path = tmp_path / "t.gcode" + rc = totem.main( + [ + "--out", + str(out_path), + "--shape", + "cylinder:10:10", + "--tool", + "end_6mm", # seeded by tools.load() on first call + "--feed", + "999", # explicit override wins over tool default + ] + ) + assert rc == 0 + text = out_path.read_text() + # end_6mm seeds spindle_rpm=12000; not overridden -> from tool. + assert "S12000" in text + # Explicit --feed 999 wins over tool's feed_mm_min=600. + assert "F999" in text + assert "F600" not in text diff --git a/tests/test_vcarve.py b/tests/test_vcarve.py new file mode 100644 index 0000000..8310dac --- /dev/null +++ b/tests/test_vcarve.py @@ -0,0 +1,493 @@ +"""Tests for rollingmill.vcarve. + +Geometry is exercised with synthetic masks so Inkscape is not required. The +heavy deps (numpy/scipy/scikit-image/pillow) are optional, so skip cleanly +when they are absent. +""" + +import math + +import pytest + +np = pytest.importorskip("numpy") +pytest.importorskip("skimage") +pytest.importorskip("scipy") +pytest.importorskip("PIL") + +from rollingmill import vcarve # noqa: E402 +from rollingmill.vcarve import ToolPath, VCarveOpts # noqa: E402 + +_HALF = math.tan(math.radians(30)) + + +def _write_svg(tmp_path, body, w=100, h=50): + svg = ( + f'{body}' + ) + p = tmp_path / "in.svg" + p.write_text(svg) + return p + + +def _bar_mask(h=100, w=200, top=40, bot=60, left=10, right=190): + """A filled horizontal bar; thickness = bot-top pixels.""" + m = np.zeros((h, w), dtype=bool) + m[top:bot, left:right] = True + return m + + +def test_depth_from_clearance_matches_bit_geometry(): + opts = VCarveOpts(half_angle_deg=30.0, max_depth_mm=100.0) + # r = d * tan(t) => d = r / tan(t) + assert vcarve._depth_from_clearance(5.0, opts) == pytest.approx(5.0 / math.tan(math.radians(30))) + # clamps to max_depth + opts2 = VCarveOpts(half_angle_deg=30.0, max_depth_mm=2.0) + assert vcarve._depth_from_clearance(5.0, opts2) == pytest.approx(2.0) + + +def test_bar_medial_axis_depth_follows_width(): + # 20 px thick bar at 0.5 mm/px -> 10 mm wide -> half-width 5 mm. + opts = VCarveOpts(page_w_mm=100.0, page_h_mm=50.0, pitch_mm=0.5, + half_angle_deg=30.0, max_depth_mm=100.0, prune_mm=0.0, + chord_tol_mm=0.01, min_depth_mm=0.0) + paths = vcarve.build_toolpaths(_bar_mask(), opts) + assert paths, "expected at least one toolpath" + got = max(p.max_depth for p in paths) + expected = 5.0 / math.tan(math.radians(30)) # ~8.66 mm + assert got == pytest.approx(expected, abs=0.4) + + +def test_y_axis_is_flipped_to_bed_coordinates(): + # Bar near the TOP of the page (rows 10..20 -> svg-y ~7.5 mm). On a 50 mm + # page the flipped bed-y must land near the FAR edge (~42.5 mm), not ~7.5. + opts = VCarveOpts(page_w_mm=100.0, page_h_mm=50.0, pitch_mm=0.5, prune_mm=0.0) + paths = vcarve.build_toolpaths(_bar_mask(top=10, bot=20), opts) + ys = [y for p in paths for (_x, y, _z) in p.pts] + assert ys and all(y > 40.0 for y in ys) # flipped to the far edge + + +def test_min_depth_floors_faint_thin_lines(): + m = np.zeros((60, 200), dtype=bool) + m[29:31, 10:190] = True # 2 px (1 mm) thin line -> natural depth ~0.87 mm + opts = VCarveOpts(page_w_mm=100.0, page_h_mm=30.0, pitch_mm=0.5, + min_depth_mm=1.5, prune_mm=0.0, max_depth_mm=100.0) + paths = vcarve.build_toolpaths(m, opts) + assert paths + depths = [z for p in paths for (_x, _y, z) in p.pts] + # whole faint branch promoted to the floor (1.5 > natural 0.87) + assert min(depths) == pytest.approx(1.5, abs=1e-6) + assert max(depths) == pytest.approx(1.5, abs=1e-6) + + +def test_passes_split_by_stepdown(): + path = ToolPath([(0.0, 0.0, 3.0), (10.0, 0.0, 3.0)]) + opts = VCarveOpts(stepdown_mm=0.6) + passes = [seq for seq, _arcs in vcarve._passes_for(path, opts)] + assert len(passes) == 5 # ceil(3.0 / 0.6) + deepest = min(z for seg in passes for (_x, _y, z) in seg) + assert deepest == pytest.approx(-3.0) + # First pass floors at -0.6, alternate passes reverse direction. + assert min(z for _x, _y, z in passes[0]) == pytest.approx(-0.6) + assert passes[1][0][0] == pytest.approx(path.pts[-1][0]) # reversed start + + +def test_emit_gcode_has_wellformed_header_and_footer(): + path = ToolPath([(10.0, 10.0, 1.0), (20.0, 10.0, 1.5), (30.0, 10.0, 0.2)]) + opts = VCarveOpts(spindle_rpm=14000, feed=400.0, plunge_feed=80.0, workspace="G54") + g = vcarve.emit_gcode([path], opts) + lines = g.splitlines() + assert lines[0].startswith("(Generated by rollingmill.vcarve") + for token in ("%", "G21", "G90", "G17", "G54", "S14000", "M03", "M05", "M30"): + assert any(line == token or line.startswith(token) for line in lines), token + # cutting moves never go above the surface (Z <= 0 on G01), spindle stops last + assert lines[-1] == "M30" + assert "M05" in lines + + +def test_stroke_only_svg_needs_no_inkscape_and_uses_declared_width(tmp_path): + pytest.importorskip("svgelements") + # A 2 mm stroke -> constant depth = (2/2)/tan(30) = 1.732 mm, everywhere. + svg = _write_svg(tmp_path, '') + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, half_angle_deg=30, + max_depth_mm=10, markers=False) + ids, strokes, has_fill = vcarve.parse_svg_features(svg, opts) + assert not has_fill and len(strokes) == 1 + assert strokes[0].width_mm == pytest.approx(2.0, abs=1e-3) + # run() must not touch Inkscape when there are no fills. + g = vcarve.run(svg, opts, is_png=False) + zs = [float(tok[1:]) for line in g.splitlines() if line.startswith("G01") + for tok in line.split() if tok.startswith("Z")] + assert min(zs) == pytest.approx(-1.0 / _HALF, abs=1e-3) # deepest = 1.732 + # Y is flipped: svg y=25 on a 50 mm page -> bed y=25. + ys = [float(tok[1:]) for line in g.splitlines() if line.startswith(("G00", "G01")) + for tok in line.split() if tok.startswith("Y")] + assert ys and all(24.0 <= y <= 26.0 for y in ys) + + +def test_crossing_strokes_do_not_deepen_at_the_intersection(tmp_path): + pytest.importorskip("svgelements") + # Two 2 mm strokes forming an X. Medial-axis rasterising would dive at the + # crossing; vector handling keeps every point at the constant stroke depth. + body = ('' + '') + svg = _write_svg(tmp_path, body, w=100, h=100) + opts = VCarveOpts(page_w_mm=100, page_h_mm=100, half_angle_deg=30, + max_depth_mm=10, markers=False) + g = vcarve.run(svg, opts, is_png=False) + zs = [float(tok[1:]) for line in g.splitlines() if line.startswith("G01") + for tok in line.split() if tok.startswith("Z")] + assert min(zs) == pytest.approx(-1.0 / _HALF, abs=1e-3) # no deeper than 1.732 + + +def test_fill_and_stroke_are_classified_separately(tmp_path): + pytest.importorskip("svgelements") + body = ('' + '') + svg = _write_svg(tmp_path, body) + opts = VCarveOpts(page_w_mm=100, page_h_mm=50) + ids, strokes, fill_elems = vcarve.parse_svg_features(svg, opts) + assert [e[0] for e in fill_elems] == ["bg"] # the rect, by id + assert "ln" in ids and len(strokes) == 1 # the line is a stroke, not a fill + + +def test_marker_end_is_expanded_into_extra_grooves(tmp_path): + pytest.importorskip("svgelements") + body = ( + '' + '' + '' + '' + ) + svg = _write_svg(tmp_path, body) + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, max_depth_mm=10) + _ids, strokes, _hf = vcarve.parse_svg_features(svg, opts) + specs = vcarve._marker_specs(svg, strokes, opts) + assert specs, "expected the arrowhead marker to expand" + # arrowhead sits at the line's end (x=90 -> within a few mm of the tip) + xs = [x for s in specs for sp in s.subpaths for (x, _y) in sp] + assert max(xs) >= 88.0 + + +def test_miter_join_spur_points_to_the_outer_corner(): + from rollingmill.vcarve import StrokeSpec + # 90 deg corner: up then right. Outer (convex) side is upper-left. + sp = [(0.0, -10.0), (0.0, 0.0), (10.0, 0.0)] + spec = StrokeSpec(subpaths=[sp], width_mm=2.0) # r=1, miter tip = V + r*(-1, 1) + spurs = vcarve._join_spurs(sp, 1, closed=False, r=1.0, depth=2.0, spec=spec) + assert len(spurs) == 1 + base, tip = spurs[0] + assert base[:2] == (0.0, 0.0) and base[2] == pytest.approx(2.0) # full depth at vertex + assert tip[0] == pytest.approx(-1.0, abs=1e-6) and tip[1] == pytest.approx(1.0, abs=1e-6) + assert tip[2] == pytest.approx(0.0) # ramps to the surface at the point + + +def test_miterlimit_falls_back_to_bevel_two_spurs(): + from rollingmill.vcarve import StrokeSpec + # A near-hairpin (~168 deg turn): 1/sin(theta/2) huge -> exceeds miterlimit 4. + sp = [(0.0, 0.0), (10.0, 0.0), (0.2, 1.0)] + spec = StrokeSpec(subpaths=[sp], width_mm=2.0, miterlimit=4.0) + spurs = vcarve._join_spurs(sp, 1, closed=False, r=1.0, depth=2.0, spec=spec) + assert len(spurs) == 2 # bevel: one spur to each shoulder + # explicit bevel request also yields two + spec2 = StrokeSpec(subpaths=[sp], width_mm=2.0, linejoin="bevel") + assert len(vcarve._join_spurs(sp, 1, False, 1.0, 2.0, spec2)) == 2 + + +def test_round_join_adds_no_spur(): + from rollingmill.vcarve import StrokeSpec + sp = [(0.0, -10.0), (0.0, 0.0), (10.0, 0.0)] + spec = StrokeSpec(subpaths=[sp], width_mm=2.0, linejoin="round") + assert vcarve._join_spurs(sp, 1, False, 1.0, 2.0, spec) == [] + + +def test_smooth_curve_registers_no_corners_but_a_triangle_has_three(): + # Sampled circle -> no sharp vertices. + circle = [(math.cos(a) * 20, math.sin(a) * 20) for a in + [i / 200 * 2 * math.pi for i in range(200)]] + circle.append(circle[0]) + assert vcarve._corner_indices(circle, closed=True) == [] + # Triangle -> three corners. + tri = [(0.0, 0.0), (20.0, 0.0), (10.0, 17.0), (0.0, 0.0)] + assert len(vcarve._corner_indices(tri, closed=True)) == 3 + + +def test_triangle_stroke_gets_a_groove_plus_three_sharpening_spurs(): + from rollingmill.vcarve import StrokeSpec + tri = [(10.0, 10.0), (40.0, 10.0), (25.0, 36.0), (10.0, 10.0)] + spec = StrokeSpec(subpaths=[tri], width_mm=2.0) + opts = VCarveOpts(page_w_mm=60, page_h_mm=60, half_angle_deg=30, max_depth_mm=10) + tps = vcarve._stroke_toolpaths(spec, opts) + # one closed groove (4-pt loop) + three 2-pt miter spurs + spurs = [t for t in tps if len(t.pts) == 2] + assert len(spurs) == 3 + assert all(min(p[2] for p in t.pts) == pytest.approx(0.0) for t in spurs) + assert len(tps) == 4 + + +def test_butt_cap_trims_groove_and_adds_end_spurs(): + from rollingmill.vcarve import StrokeSpec + line = [(10.0, 25.0), (90.0, 25.0)] # 80 mm long, width 2 -> r=1 + spec = StrokeSpec(subpaths=[line], width_mm=2.0, linecap="butt") + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, half_angle_deg=30, max_depth_mm=10) + tps = vcarve._stroke_toolpaths(spec, opts) + # groove is trimmed by r at each end: spans ~11..89 in X + groove = max(tps, key=lambda t: len(t.pts)) + xs = [p[0] for p in groove.pts] + assert min(xs) == pytest.approx(11.0, abs=1e-6) and max(xs) == pytest.approx(89.0, abs=1e-6) + # four cap spurs (two per end), each ramping to the surface + caps = [t for t in tps if len(t.pts) == 2 and min(p[2] for p in t.pts) <= 1e-9] + assert len(caps) == 4 + + +def test_circle_fit_recovers_centre_and_radius(): + pts = [(10 + 5 * math.cos(t), 20 + 5 * math.sin(t)) for t in + [i * 0.2 for i in range(20)]] + cx, cy, r = vcarve._circle_fit(pts) + assert (cx, cy, r) == pytest.approx((10.0, 20.0, 5.0), abs=1e-6) + # collinear points -> no circle + assert vcarve._circle_fit([(0, 0), (1, 0), (2, 0), (3, 0)]) is None + + +def test_arc_toolpath_reversal_flips_direction_and_remaps(): + from rollingmill.vcarve import ToolPath + tp = ToolPath([(0.0, 0.0, 1.0), (1.0, 0.0, 1.0), (2.0, 0.0, 1.0)], {0: (0.5, 0.5, True)}) + r = tp.reversed() + assert r.pts[0] == (2.0, 0.0, 1.0) + assert r.arcs == {1: (0.5, 0.5, False)} # segment index remapped, cw flipped + + +def test_circular_stroke_becomes_one_arc_and_emits_g02_g03(): + from rollingmill.vcarve import StrokeSpec + # a semicircle radius 20 about (50,50); round cap so no cap spurs/trim + sub = [(50 + 20 * math.cos(t), 50 - 20 * math.sin(t)) for t in + [i / 40 * math.pi for i in range(41)]] + opts = VCarveOpts(page_w_mm=100, page_h_mm=100, half_angle_deg=30, + max_depth_mm=10, markers=False, arcs=True) + spec = StrokeSpec(subpaths=[sub], width_mm=2.0, linecap="round") + tps = vcarve._stroke_toolpaths(spec, opts) + main = max(tps, key=lambda t: len(t.pts)) + assert len(main.arcs) == 1 and len(main.pts) == 3 # whole semicircle -> one arc + # reconstruct the arc centre from the emitted I/J and check radius + cx, cy, cw = main.arcs[0] + p0 = main.pts[0] + assert math.hypot(p0[0] - cx, p0[1] - cy) == pytest.approx(20.0, abs=0.05) + g = vcarve.emit_gcode(tps, opts) + assert "G02" in g or "G03" in g + assert "I" in g and "J" in g + + +def test_no_arcs_flag_keeps_straight_g01_only(): + from rollingmill.vcarve import StrokeSpec + sub = [(50 + 20 * math.cos(t), 50 - 20 * math.sin(t)) for t in + [i / 40 * math.pi for i in range(41)]] + opts = VCarveOpts(page_w_mm=100, page_h_mm=100, max_depth_mm=10, + markers=False, arcs=False) + spec = StrokeSpec(subpaths=[sub], width_mm=2.0, linecap="round") + tps = vcarve._stroke_toolpaths(spec, opts) + assert all(not t.arcs for t in tps) + assert "G02" not in vcarve.emit_gcode(tps, opts) + + +def test_straight_stroke_fits_no_arc(): + from rollingmill.vcarve import StrokeSpec + line = [(10.0, 25.0), (90.0, 25.0)] + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, max_depth_mm=10, markers=False) + spec = StrokeSpec(subpaths=[line], width_mm=2.0, linecap="round") + tps = vcarve._stroke_toolpaths(spec, opts) + assert all(not t.arcs for t in tps) + + +def test_fill_annulus_recovers_arcs(): + # A filled ring: its medial axis is a circle, so the fill carve should emit + # arcs rather than a chain of chords. + n = 200 + yy, xx = np.mgrid[0:n, 0:n] + rr = np.hypot(xx - n / 2, yy - n / 2) * 0.5 # pitch 0.5 mm -> radius ~30 mm + mask = (rr >= 28.0) & (rr <= 32.0) # 4 mm wide ring + opts = VCarveOpts(page_w_mm=100, page_h_mm=100, pitch_mm=0.5, half_angle_deg=45, + max_depth_mm=10, fill_arcs=True, fill_arc_tol_mm=0.3, prune_mm=1.0) + tps = vcarve.build_toolpaths(mask, opts) + assert sum(len(t.arcs) for t in tps) >= 1 # ring recovered as arc(s) + + +def test_convex_diamond_medial_does_not_collapse(): + # A filled diamond (rotated square) is convex, so skimage.medial_axis/ + # skeletonize collapse it to ~2 px (a "dot"). The distance-transform ridge + # must instead recover the NSEW "+" that spans the diamond. + n = 200 + yy, xx = np.mgrid[0:n, 0:n] + diamond = (np.abs(xx - n / 2) + np.abs(yy - n / 2)) <= 80 # 8 mm half-diagonal at 0.1 + opts = VCarveOpts(page_w_mm=20, page_h_mm=20, pitch_mm=0.1, half_angle_deg=45, + max_depth_mm=20, prune_mm=0.5, fill_arcs=False) + tps = vcarve.build_toolpaths(diamond, opts) + xs = [p[0] for t in tps for p in t.pts] + ys = [p[1] for t in tps for p in t.pts] + assert tps and xs and ys + assert (max(xs) - min(xs)) > 10.0 and (max(ys) - min(ys)) > 10.0 # spans, not a dot + + +def test_round_disk_keeps_clean_medial_not_noisy_ridge(): + # A filled disk's medial axis is a point -> few toolpaths. The ridge repair + # (used for collapsed *non-round* shapes like a diamond) is noisy, so it must + # NOT be applied to a round disk (which would explode the toolpath count). + n = 200 + yy, xx = np.mgrid[0:n, 0:n] + disk = np.hypot(xx - n / 2, yy - n / 2) <= 80 + opts = VCarveOpts(page_w_mm=20, page_h_mm=20, pitch_mm=0.1, half_angle_deg=45, + max_depth_mm=20, prune_mm=0.5) + assert len(vcarve.build_toolpaths(disk, opts)) < 30 # clean, not a ridge blow-up + + +def test_tiny_dot_is_not_dropped(): + # A 1 px-radius filled dot (e.g. a colon/period dot at small size): the ridge + # medial collapses to a single pixel, which _trace_skeleton must still emit + # as a point so the dot carves instead of vanishing. + n = 40 + yy, xx = np.mgrid[0:n, 0:n] + dot = np.hypot(xx - n / 2, yy - n / 2) <= 1 + opts = VCarveOpts(page_w_mm=4, page_h_mm=4, pitch_mm=0.05, half_angle_deg=45, + max_depth_mm=10, min_depth_mm=0.3, prune_mm=0.4) + assert vcarve.build_toolpaths(dot, opts) # at least one toolpath (not dropped) + + +def test_fills_emit_no_arcs_by_default(): + n = 200 + yy, xx = np.mgrid[0:n, 0:n] + rr = np.hypot(xx - n / 2, yy - n / 2) * 0.5 + mask = (rr >= 28.0) & (rr <= 32.0) + opts = VCarveOpts(page_w_mm=100, page_h_mm=100, pitch_mm=0.5, half_angle_deg=45, + max_depth_mm=10, fill_arcs=False, prune_mm=1.0) + tps = vcarve.build_toolpaths(mask, opts) + assert all(not t.arcs for t in tps) + + +def test_helical_arc_emits_z_only_when_depth_ramps(): + from rollingmill.vcarve import ToolPath + opts = VCarveOpts(stepdown_mm=100.0) # single pass + ramp = ToolPath([(0.0, 0.0, 2.0), (10.0, 0.0, 0.5)], {0: (5.0, 3.0, True)}) + arc_line = [ln for ln in vcarve.emit_gcode([ramp], opts).splitlines() + if ln.startswith(("G02", "G03"))][0] + assert "Z" in arc_line and "I" in arc_line # helical: has a Z word + flat = ToolPath([(0.0, 0.0, 1.0), (10.0, 0.0, 1.0)], {0: (5.0, 3.0, True)}) + flat_line = [ln for ln in vcarve.emit_gcode([flat], opts).splitlines() + if ln.startswith(("G02", "G03"))][0] + assert "Z" not in flat_line # constant depth -> planar arc, no Z + + +def test_vector_medial_diamond_is_a_cross_not_a_dot(): + pytest.importorskip("scipy") + # a filled diamond (rotated square) -> the vector medial must be the NSEW "+" + # reaching all four corners, not a collapsed point. + R = 6.0 + ring = [(10 + R, 10), (10, 10 + R), (10 - R, 10), (10, 10 - R), (10 + R, 10)] + opts = VCarveOpts(page_w_mm=20, page_h_mm=20, half_angle_deg=45, max_depth_mm=20, + min_depth_mm=0.0, prune_mm=0.5) + tps = vcarve.build_fill_toolpaths([("d", [ring])], opts) + xs = [p[0] for t in tps for p in t.pts] + ys = [p[1] for t in tps for p in t.pts] + assert tps and (max(xs) - min(xs)) > 10.0 and (max(ys) - min(ys)) > 10.0 # spans, "+" + + +def test_vector_medial_tiny_dot_is_not_dropped(): + pytest.importorskip("scipy") + import math as _m + ring = [(5 + 0.4 * _m.cos(t), 5 + 0.4 * _m.sin(t)) for t in + [i / 24 * 2 * _m.pi for i in range(25)]] + opts = VCarveOpts(page_w_mm=10, page_h_mm=10, half_angle_deg=45, max_depth_mm=10, + min_depth_mm=0.3, prune_mm=0.4) + assert vcarve.build_fill_toolpaths([("dot", [ring])], opts) # carves (not dropped) + + +def test_run_writes_both_preview_png_and_svg(tmp_path): + pytest.importorskip("svgelements") + svg = _write_svg(tmp_path, '') + base = tmp_path / "prev.png" + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, preview_path=base) + vcarve.run(svg, opts, is_png=False) + assert base.exists() and base.with_suffix(".svg").exists() + svg_txt = base.with_suffix(".svg").read_text() + assert "= 1 # normal stroke fits an arc + marker = vcarve._stroke_toolpaths( + StrokeSpec(subpaths=[arc], width_mm=2.0, is_marker=True), opts) + assert all(not t.arcs for t in marker) # marker geometry never does + + +def test_preview_png_is_written_with_ink(tmp_path): + from PIL import Image + + from rollingmill.vcarve import ToolPath + paths = [ToolPath([(10.0, 10.0, 2.0), (90.0, 10.0, 0.5)])] + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, max_depth_mm=3) + out = tmp_path / "preview.png" + vcarve.render_preview(paths, opts, out) + arr = np.asarray(Image.open(out).convert("L")) + assert out.exists() and (arr < 250).any() # something was drawn + + +def test_run_writes_preview_when_requested(tmp_path): + pytest.importorskip("svgelements") + svg = _write_svg(tmp_path, '') + prev = tmp_path / "p.png" + opts = VCarveOpts(page_w_mm=100, page_h_mm=50, preview_path=prev) + vcarve.run(svg, opts, is_png=False) + assert prev.exists() + + +def test_png_roundtrip_mask(tmp_path): + from PIL import Image + + arr = np.full((20, 30), 255, dtype=np.uint8) + arr[5:15, 8:22] = 0 # black block + png = tmp_path / "m.png" + Image.fromarray(arr, mode="L").save(png) + mask = vcarve.load_png_to_mask(png, threshold=200) + assert mask.dtype == bool + assert mask[10, 15] and not mask[0, 0]