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.

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=abey79_vpype&pullRequest=110&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=abey79_vpype&pullRequest=110&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=abey79_vpype&pullRequest=110&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 0c915be..de76b0d 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -8,6 +8,8 @@ _to be completed_
 - [ ] code formatting ok (`black` and `isort`)
 - [ ] `mypy vpype vpype_cli tests` returns no error
 - [ ] tests added/updated and `pytest` succeeds
-- [ ] CHANGELOG.md updated
-- [ ] README.md updated (Feature Overview)
-- [ ] documentation added/updated and building with no error (`make clean && make html` in `docs/`)
+- [ ] documentation added/updated
+    - [ ] command docstring and option/argument `help`
+    - [ ] README.md updated (Feature Overview)
+    - [ ] CHANGELOG.md updated
+    - [ ] RTD doc updated and building with no error (`make clean && make html` in `docs/`)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c223699..f38e416 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,15 @@
 #### 1.1.0 (UNRELEASED)
 
+New features and improvements:
+* Added `snap` command (#110)
 * Invisible SVG elements are now discarded (#103)
+
+API changes:
+* Added vpype_cli.execute() to execute a vpype pipeline from python (#104)
+
+Bug fixes:
 * Fixed `write` to cap SVG width and height to a minimum of 1px (#102)
 * Fixed grouping of `stat` command in `vpype --help`
-* API: added vpype_cli.execute() to execute a vpype pipeline from python (#104)
-* Bump svgelements from 1.3.2 to 1.3.4 (#101)
 
 
 #### 1.0.0 (2020-11-29)
diff --git a/docs/reference.rst b/docs/reference.rst
index 56ec02e..943dc57 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -127,6 +127,10 @@ CLI reference
 .. click:: vpype_cli:skew
    :prog: skew
 
+.. _cmd_snap:
+.. click:: vpype_cli:snap
+   :prog: snap
+
 .. _cmd_splitall:
 .. click:: vpype_cli:splitall
    :prog: splitall
diff --git a/vpype_cli/operations.py b/vpype_cli/operations.py
index 25892a5..8907e69 100644
--- a/vpype_cli/operations.py
+++ b/vpype_cli/operations.py
@@ -17,6 +17,7 @@ __all__ = (
     "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
index 00580cb..0fbb4af 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -7,7 +7,7 @@ import pytest
 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 @@ MINIMAL_COMMANDS = [
     "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
index 592c96b..06c38f2 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -4,6 +4,7 @@ from typing import Sequence
 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]