Repository: abey79/vpype

Issue:
Feature: add `snap` command
Snap everything to an arbitrary grid with deduplication of line vertices:

```python
@click.command()
@click.argument(
    "pitch",
    type=Length(),
    default="1mm",
    help="Grid pitch",
)
@layer_processor
def snap(lines: LineCollection, pitch: float) -> LineCollection:
    """
    Move everything on a grid.
    """

    lines.scale(1/pitch)

    new_lines = LineCollection()
    for line in lines:
        new_line = np.round(line)
        idx = np.ones(len(new_line), dtype=bool)
        idx[1:] = new_line[1:] != new_line[:-1]
        new_lines.append(np.round(new_line[idx]))

    new_lines.scale(pitch)
    return new_lines
```

An additional `offset` option should be added too.
Feature: add `snap` command
Snap everything to an arbitrary grid with deduplication of line vertices:

```python
@click.command()
@click.argument(
    "pitch",
    type=Length(),
    default="1mm",
    help="Grid pitch",
)
@layer_processor
def snap(lines: LineCollection, pitch: float) -> LineCollection:
    """
    Move everything on a grid.
    """

    lines.scale(1/pitch)

    new_lines = LineCollection()
    for line in lines:
        new_line = np.round(line)
        idx = np.ones(len(new_line), dtype=bool)
        idx[1:] = new_line[1:] != new_line[:-1]
        new_lines.append(np.round(new_line[idx]))

    new_lines.scale(pitch)
    return new_lines
```

An additional `offset` option should be added too.


Discussion:

Resolving patch:
diff --git a/vpype_cli/operations.py b/vpype_cli/operations.py
--- a/vpype_cli/operations.py
+++ b/vpype_cli/operations.py
@@ -17,6 +17,7 @@
     "multipass",
     "pagesize",
     "reloop",
+    "snap",
     "splitall",
     "trim",
 )
@@ -342,3 +343,34 @@ def pagesize(document: vp.Document, size, landscape) -> vp.Document:
 
     document.page_size = size[::-1] if landscape else size
     return document
+
+
+@cli.command(group="Operations")
+@click.argument("pitch", type=vp.LengthType(), required=True)
+@vp.layer_processor
+def snap(line_collection: vp.LineCollection, pitch: float) -> vp.LineCollection:
+    """Snap all points to a grid with with a spacing of PITCH.
+
+    This command moves every point of every paths to the nearest grid point. If sequential
+    points of a segment end up on the same grid point, they are deduplicated. If the resulting
+    path contains less than 2 points, it is discarded.
+
+    Example:
+
+        Snap all points to a grid of 3x3mm:
+
+            vpype [...] snap 3mm [...]
+    """
+
+    line_collection.scale(1 / pitch)
+
+    new_lines = vp.LineCollection()
+    for line in line_collection:
+        new_line = np.round(line)
+        idx = np.ones(len(new_line), dtype=bool)
+        idx[1:] = new_line[1:] != new_line[:-1]
+        if idx.sum() > 1:
+            new_lines.append(np.round(new_line[idx]))
+
+    new_lines.scale(pitch)
+    return new_lines


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -7,7 +7,7 @@
 import vpype as vp
 from vpype_cli import DebugData, cli, execute
 
-from .utils import TESTS_DIRECTORY
+from .utils import TESTS_DIRECTORY, execute_single_line
 
 CM = 96 / 2.54
 
@@ -46,6 +46,7 @@
     "filter --min-length 1mm",
     "pagesize 10inx15in",
     "stat",
+    "snap 1",
 ]
 
 
@@ -330,3 +331,24 @@ def test_linesort_no_flip(runner):
     data = DebugData.load(res.output)[0]
     assert res.exit_code == 0
     assert 24.13 < data.pen_up_length < 24.15
+
+
+def test_snap():
+    line = np.array([0.2, 0.8 + 1.1j, 0.5 + 2.5j])
+    lc = execute_single_line("snap 1", line)
+
+    assert len(lc) == 1
+    assert np.all(lc[0] == np.array([0, 1 + 1j, 2j]))
+
+
+@pytest.mark.parametrize("pitch", [0.1, 1, 5, 10, 20, 50, 100, 200, 500])
+def test_snap_no_duplicate(pitch: float):
+    """Snap should return no duplicated points and reject lines that degenerate into a single
+    point."""
+    lc = execute_single_line(f"snap {pitch}", vp.circle(0, 0, 100, quantization=1))
+
+    if len(lc) == 1:
+        assert len(lc[0]) > 1
+        assert np.all(lc[0][:-1] != lc[0][1:])
+    else:
+        assert len(lc) == 0
diff --git a/tests/utils.py b/tests/utils.py
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -4,6 +4,7 @@
 import numpy as np
 
 import vpype as vp
+from vpype_cli import execute
 
 TESTS_DIRECTORY = pathlib.Path(__file__).parent
 
@@ -17,3 +18,14 @@ def line_collection_contains(lc: vp.LineCollection, line: Sequence[complex]) ->
             return True
 
     return False
+
+
+def execute_single_line(pipeline: str, line: np.ndarray) -> vp.LineCollection:
+    """Execute a pipeline on a single line. The pipeline is expected to remain single layer.
+
+    Returns:
+        the layer 1's LineCollection
+    """
+    doc = execute(pipeline, vp.Document(vp.LineCollection([line])))
+    assert len(doc.layers) == 1
+    return doc.layers[1]