Repository: abey79/vpype

Issue:
Add trim command
As per discussion in #41 


Discussion:

Resolving patch:
diff --git a/vpype/model.py b/vpype/model.py
--- a/vpype/model.py
+++ b/vpype/model.py
@@ -142,10 +142,13 @@ def crop(self, x1: float, y1: float, x2: float, y2: float) -> None:
         if y1 > y2:
             y1, y2 = y2, y1
 
-        new_lines = []
-        for line in self._lines:
-            new_lines.extend(crop(line, x1, y1, x2, y2))
-        self._lines = new_lines
+        if x1 == x2 or y1 == y2:
+            self._lines = []
+        else:
+            new_lines = []
+            for line in self._lines:
+                new_lines.extend(crop(line, x1, y1, x2, y2))
+            self._lines = new_lines
 
     def merge(self, tolerance: float, flip: bool = True) -> None:
         """Merge lines whose endings overlap or are very close.
@@ -319,9 +322,8 @@ def rotate(self, angle: float) -> None:
     def bounds(
         self, layer_ids: Union[None, Iterable[int]] = None
     ) -> Optional[Tuple[float, float, float, float]]:
-        """
-        Compute bounds of the vector data. If `layer_ids` is provided, bounds are computed only
-        the corresponding ids.
+        """Compute bounds of the vector data. If `layer_ids` is provided, bounds are computed
+        only the corresponding ids.
 
         :param layer_ids: layers to consider
         :return: boundaries of the geometries
diff --git a/vpype_cli/operations.py b/vpype_cli/operations.py
--- a/vpype_cli/operations.py
+++ b/vpype_cli/operations.py
@@ -1,10 +1,19 @@
 import logging
+from typing import Union, List
 
 import click
 import numpy as np
-from shapely.geometry import LineString
 
-from vpype import LineCollection, LengthType, layer_processor, LineIndex
+from vpype import (
+    LineCollection,
+    LengthType,
+    layer_processor,
+    LineIndex,
+    global_processor,
+    multiple_to_layer_ids,
+    VectorData,
+    LayerType,
+)
 from .cli import cli
 
 
@@ -25,6 +34,52 @@ def crop(lines: LineCollection, x: float, y: float, width: float, height: float)
     return lines
 
 
+@cli.command(group="Operations")
+@click.argument("margin_x", type=LengthType(), required=True)
+@click.argument("margin_y", type=LengthType(), required=True)
+@click.option(
+    "-l",
+    "--layer",
+    type=LayerType(accept_multiple=True),
+    default="all",
+    help="Target layer(s).",
+)
+@global_processor
+def trim(
+    vector_data: VectorData, margin_x: float, margin_y: float, layer: Union[int, List[int]]
+) -> VectorData:
+    """Trim the geometries by some margin.
+
+    This command trims the geometries by the provided X and Y margins with respect to the
+    current bounding box.
+
+    By default, `trim` acts on all layers. If one or more layer IDs are provided with the
+    `--layer` option, only these layers will be affected. In this case, the bounding box is
+    that of the listed layers.
+    """
+
+    layer_ids = multiple_to_layer_ids(layer, vector_data)
+    bounds = vector_data.bounds(layer_ids)
+
+    if not bounds:
+        return vector_data
+
+    min_x = bounds[0] + margin_x
+    max_x = bounds[2] - margin_x
+    min_y = bounds[1] + margin_y
+    max_y = bounds[3] - margin_y
+    if min_x > max_x:
+        min_x = max_x = 0.5 * (min_x + max_x)
+    if min_y > max_y:
+        min_y = max_y = 0.5 * (min_y + max_y)
+
+    for vid in layer_ids:
+        lc = vector_data[vid]
+        lc.crop(min_x, min_y, max_x, max_y)
+
+    return vector_data
+
+
 @cli.command(group="Operations")
 @click.option(
     "-t",


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -32,6 +32,7 @@
     "lmove 1 new",
     "lcopy 1 new",
     "ldelete 1",
+    "trim 1mm 1mm",
 ]
 
 
@@ -207,6 +208,41 @@ def test_crop_line_flush(runner):
     assert data[0].count == 1
 
 
+def test_crop_empty(runner):
+    res = runner.invoke(cli, "random -a 10cm 10cm -n 1000 crop 5cm 5cm 0 1cm dbsample dbdump")
+    data = DebugData.load(res.output)
+    assert res.exit_code == 0
+    assert data[0].count == 0
+
+
+def test_crop_empty2(runner):
+    res = runner.invoke(cli, "random -a 10cm 10cm -n 1000 crop 5cm 5cm 0 0 dbsample dbdump")
+    data = DebugData.load(res.output)
+    assert res.exit_code == 0
+    assert data[0].count == 0
+
+
+def test_trim(runner):
+    res = runner.invoke(cli, "random -a 10cm 10cm -n 1000 trim 1cm 2cm dbsample dbdump")
+    data = DebugData.load(res.output)
+    assert res.exit_code == 0
+    assert data[0].bounds_within(CM, 2 * CM, 9 * CM, 8 * CM)
+
+
+def test_trim_large_margins(runner):
+    res = runner.invoke(cli, "random -a 10cm 10cm -n 1000 trim 10cm 2cm dbsample dbdump")
+    data = DebugData.load(res.output)
+    assert res.exit_code == 0
+    assert data[0].count == 0
+
+
+def test_trim_large_margins2(runner):
+    res = runner.invoke(cli, "random -a 10cm 10cm -n 1000 trim 10cm 20cm dbsample dbdump")
+    data = DebugData.load(res.output)
+    assert res.exit_code == 0
+    assert data[0].count == 0
+
+
 @pytest.mark.parametrize(
     ("linemerge_args", "expected"),
     [
@@ -231,8 +267,8 @@ def test_linemerge(runner, linemerge_args, expected):
 @pytest.mark.parametrize(
     "lines",
     [
-        " ".join(l)
-        for l in itertools.permutations(
+        " ".join(s)
+        for s in itertools.permutations(
             ["line 0 0 0 10", "line 0 10 10 10", "line 0 0 10 0", "line 10 0 10 10"]
         )
     ],