├── img ├── example1.png ├── example2.png ├── example3.png ├── example4.png ├── example5.png ├── example6.png ├── example7.png ├── example8.png ├── example9.png ├── example10.png └── example11.png ├── requirements.txt ├── pyproject.toml ├── .gitignore ├── setup.py ├── LICENSE ├── .github └── workflows │ └── release.yml ├── README.md └── occult └── occult.py /img/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example1.png -------------------------------------------------------------------------------- /img/example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example2.png -------------------------------------------------------------------------------- /img/example3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example3.png -------------------------------------------------------------------------------- /img/example4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example4.png -------------------------------------------------------------------------------- /img/example5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example5.png -------------------------------------------------------------------------------- /img/example6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example6.png -------------------------------------------------------------------------------- /img/example7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example7.png -------------------------------------------------------------------------------- /img/example8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example8.png -------------------------------------------------------------------------------- /img/example9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example9.png -------------------------------------------------------------------------------- /img/example10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example10.png -------------------------------------------------------------------------------- /img/example11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LoicGoulefert/occult/HEAD/img/example11.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # base 2 | click 3 | vpype 4 | shapely 5 | 6 | # dev/test 7 | pytest 8 | black 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "numpy"] 3 | 4 | [tool.black] 5 | line-length = 95 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # Distribution / packaging 8 | .Python 9 | build/ 10 | develop-eggs/ 11 | dist/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | pip-wheel-metadata/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # SageMath parsed files 29 | *.sage.py 30 | 31 | # Environments 32 | .env 33 | .venv 34 | env/ 35 | venv/ 36 | ENV/ 37 | env.bak/ 38 | venv.bak/ 39 | 40 | # my stuff 41 | test_files/ 42 | .vscode/ 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md") as f: 4 | readme = f.read() 5 | 6 | with open("LICENSE") as f: 7 | license = f.read() 8 | 9 | setup( 10 | name="vpype-occult", 11 | version="0.5.0a1", 12 | description="Occlusion plug-in for vpype", 13 | long_description=readme, 14 | long_description_content_type="text/markdown", 15 | author="Loic Goulefert", 16 | url="https://github.com/LoicGoulefert/occult", 17 | license=license, 18 | packages=["occult"], 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Topic :: Multimedia :: Graphics", 23 | "Environment :: Plugins", 24 | ], 25 | setup_requires=["wheel"], 26 | install_requires=[ 27 | "click", 28 | "numpy", 29 | "shapely>=2.0.0", 30 | "vpype>=1.9,<2.0", 31 | ], 32 | entry_points=""" 33 | [vpype.plugins] 34 | occult=occult.occult:occult 35 | """, 36 | ) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Loïc Goulefert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | 10 | job_release: 11 | name: Create Release and Upload to PyPI 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Get tag 15 | id: tag 16 | run: | 17 | echo ::set-output name=tag::${GITHUB_REF#refs/tags/} 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up Python 3.10 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: '3.10' 25 | 26 | - name: Build 27 | id: build 28 | run: | 29 | python setup.py build sdist bdist_wheel 30 | - name: Create Release 31 | uses: "marvinpinto/action-automatic-releases@latest" 32 | with: 33 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 34 | automatic_release_tag: ${{ steps.tag.outputs.tag }} 35 | prerelease: false 36 | draft: true 37 | files: | 38 | dist/* 39 | - name: Publish to PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | user: __token__ 43 | password: ${{ secrets.PYPI_TOKEN }} 44 | verbose: true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # occult 2 | 3 | [`vpype`](https://github.com/abey79/vpype) plug-in to remove lines occulted by polygons from SVG files. 4 | 5 | 6 | ## Examples 7 | 8 | 9 | ### Basic usage 10 | 11 | Draw a line, then a square: 12 | 13 | 14 | `vpype line 0 0 5cm 5cm rect 2cm 2cm 1cm 1cm show` 15 | 16 | 17 | 18 | 19 | Same drawing, after applying `occult`: 20 | 21 | 22 | `vpype line 0 0 5cm 5cm rect 2cm 2cm 1cm 1cm occult show` 23 | 24 | 25 | 26 | 27 | Order of path is important: `occult` will consider the last geometry in a SVG file to be "on top" of all other geometries, 28 | the last but one is above every other geometries except the last one. 29 | For instance, using `vpype rect 2cm 2cm 1cm 1cm occult show` will not modify geometries. 30 | 31 | 32 | ### Working with multiple layers 33 | 34 | 35 | By default, `occult` performs occlusion layer by layer. For instance, applying occlusion 36 | on the image below will not change anything: 37 | 38 | 39 | 40 | `occult -i` ignores layers, so that occlusion is performed on all objects, regardless of their layer. 41 | Geometries in layers with a larger ID number are considered to be "on top" of geometries in layers 42 | with a smaller ID number. 43 | 44 | - Without `-i` flag 45 | 46 | 47 | 48 | - With `-i` flag 49 | 50 | 51 | 52 | `occult -a` only performs occlusions across layers, ignoring occlusions that occur within a layer. As in `occult -i`, 53 | geometries in layers with a larger ID number are considered to be "on top" of geometries in layers with a smaller ID 54 | number. This option overrides `-i`. 55 | 56 | - Without `-a` or `-i` flags 57 | 58 | 59 | 60 | - With `-i` flag 61 | 62 | 63 | 64 | - With `-a` flag 65 | 66 | 67 | 68 | ### Save occulted lines 69 | 70 | `occult -k` keeps occulted lines in a separate layers. 71 | 72 | - Without `-k` flag 73 | 74 | 75 | 76 | - With `-k` flag 77 | 78 | 79 | 80 | Using vpype's viewer (`show` command), you can visualize occulted lines and remaining lines separately. 81 | 82 | 83 | ## Using occult with Vsketch 84 | 85 | `occult` can be invoked from a [Vksetch](https://vsketch.readthedocs.io/en/latest/) sketch, using `vsk.vpype("occult")`. When using the GUI, calling `occult` within the sketch `draw()` method will display occulted geometries at each code save / seed change. For sketches with lots of geometries, occlusion can take a significant amount of time. Invoke `occult` within the `finalize()` method of a sketch to perform occlusion only when saving a specific output. 86 | 87 | 88 | ```py 89 | import vsketch 90 | 91 | class Sketch(vsketch.SketchClass): 92 | def draw(self, vsk: vsketch.Vsketch): 93 | vsk.size('10x10cm') 94 | vsk.scale('mm') 95 | 96 | vsk.line(-5, -5, 5, 5) 97 | vsk.circle(0, 0, 3) 98 | 99 | # Uncomment to perform occlusion at every GUI reload 100 | # vsk.vpype("occult") 101 | 102 | def finalize(self, vsk: vsketch.Vsketch) -> None: 103 | # Occlusion (and other vpype commands) invoked only when saving 104 | vsk.vpype("linesimplify occult linemerge linesort") 105 | 106 | 107 | if __name__ == "__main__": 108 | Sketch.display() 109 | ``` 110 | 111 | 112 | 113 | ## Installation 114 | 115 | See the [installation instructions](https://vpype.readthedocs.io/en/latest/install.html) for information on how 116 | to install `vpype`. 117 | 118 | 119 | ### Existing `vpype` installation 120 | 121 | If *vpype* was installed using pipx, use the following command: 122 | 123 | ```bash 124 | $ pipx inject vpype vpype-occult 125 | ``` 126 | 127 | If *vpype* was installed using pip in a virtual environment, activate the virtual environment and use the following command: 128 | 129 | ```bash 130 | $ pip install vpype-occult 131 | ``` 132 | 133 | Check that your install is successful: 134 | 135 | ``` 136 | $ vpype --help 137 | Usage: vpype [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]... 138 | 139 | Options: 140 | -v, --verbose 141 | -I, --include PATH Load commands from a command file. 142 | --help Show this message and exit. 143 | 144 | Commands: 145 | [...] 146 | Plugins: 147 | occult 148 | [...] 149 | ``` 150 | 151 | ### Stand-alone installation 152 | 153 | Use this method if you need to edit this project. First, clone the project: 154 | 155 | ```bash 156 | $ git clone https://github.com/LoicGoulefert/occult.git 157 | $ cd occult 158 | ``` 159 | 160 | Create a virtual environment: 161 | 162 | ```bash 163 | $ python3 -m venv venv 164 | $ source venv/bin/activate 165 | $ pip install --upgrade pip 166 | ``` 167 | 168 | Install `occult` and its dependencies (including `vpype`): 169 | 170 | ```bash 171 | $ pip install -e . 172 | ``` 173 | 174 | Check that your install is successful: 175 | 176 | ``` 177 | $ vpype --help 178 | Usage: vpype [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]... 179 | 180 | Options: 181 | -v, --verbose 182 | -I, --include PATH Load commands from a command file. 183 | --help Show this message and exit. 184 | 185 | Commands: 186 | [...] 187 | Plugins: 188 | occult 189 | [...] 190 | ``` 191 | 192 | 193 | ## Documentation 194 | 195 | The complete plug-in documentation is available directly in the CLI help: 196 | 197 | ```bash 198 | $ vpype occult --help 199 | ``` 200 | 201 | 202 | ## License 203 | 204 | See the [LICENSE](LICENSE) file for details. 205 | -------------------------------------------------------------------------------- /occult/occult.py: -------------------------------------------------------------------------------- 1 | # Standard libs 2 | import math 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | # Third party libs 6 | import click 7 | import numpy as np 8 | import vpype as vp 9 | import vpype_cli 10 | from shapely.geometry import LineString, MultiLineString, Polygon 11 | from shapely.strtree import STRtree 12 | 13 | 14 | def add_to_linecollection(lc, line): 15 | """Helper function to add a LineString or a MultiLineString to a LineCollection""" 16 | if isinstance(line, LineString) and len(line.coords) != 0: 17 | lc.append(line) 18 | elif isinstance(line, MultiLineString): 19 | lc.extend(line) 20 | 21 | return None 22 | 23 | 24 | def _occult_layer( 25 | layers: Dict[int, vp.LineCollection], tolerance: float, keep_occulted: bool = False, across_layers: bool = False 26 | ) -> Tuple[Dict[int, vp.LineCollection], vp.LineCollection]: 27 | """ 28 | Perform occlusion on all provided layers. Optionally returns occulted lines 29 | in a separate LineCollection. 30 | 31 | Args: 32 | layers: dictionary of LineCollections to perform occlusion on, keyed by layer ID 33 | tolerance: Max distance between start and end point to consider a path closed 34 | keep_occulted: if True, save removed lines in removed_lines LineCollection. 35 | Otherwise, removed_lines is an empty LineCollection. 36 | 37 | Returns: 38 | a tuple with two items: 39 | - new_lines, a dictionary of LineCollections for each layer ID received 40 | - removed_lines, a LineCollection of removed lines 41 | """ 42 | removed_lines = vp.LineCollection() 43 | new_lines = {l_id: vp.LineCollection() for l_id in layers} 44 | 45 | line_arr = [] # list of LineString objects paired with layer ID 46 | line_arr_lines = [] # list of LineString objects from all layers without layer ID 47 | for l_id, lines in layers.items(): 48 | line_arr.extend([[l_id, line] for line in lines.as_mls().geoms]) 49 | line_arr_lines.extend([line for line in lines.as_mls().geoms]) 50 | 51 | # Build R-tree which combines the geometry of all layers 52 | tree = STRtree(line_arr_lines) 53 | 54 | for i, (l_id, line) in enumerate(line_arr): 55 | coords = np.array(line.coords) 56 | 57 | if not ( 58 | len(coords) > 3 59 | and math.hypot(coords[-1, 0] - coords[0, 0], coords[-1, 1] - coords[0, 1]) 60 | < tolerance 61 | ): 62 | continue 63 | 64 | p = Polygon(coords) 65 | 66 | if not p.is_valid: 67 | continue 68 | 69 | # Find all geometries that intersect with the current polygon 70 | geom_idx = tree.query(p, predicate='intersects') 71 | geom_idx = [idx for idx in geom_idx if idx < i] # only consider geometries drawn prior to the current one 72 | if across_layers: 73 | # only consider geometries that are on a different layer 74 | geom_idx = [idx for idx in geom_idx if line_arr[idx][0] != l_id] 75 | 76 | for gi in geom_idx: 77 | # Aggregate removed lines 78 | if keep_occulted: 79 | rl = p.intersection(line_arr_lines[gi]) 80 | add_to_linecollection(removed_lines, rl) 81 | 82 | # Update previous geometries 83 | line_arr[gi][1] = line_arr[gi][1].difference(p) 84 | 85 | for (l_id, line) in line_arr: 86 | add_to_linecollection(new_lines[l_id], line) 87 | 88 | return new_lines, removed_lines 89 | 90 | 91 | @click.command() 92 | @click.option( 93 | "-t", 94 | "--tolerance", 95 | type=vpype_cli.LengthType(), 96 | default="0.01mm", 97 | help="Max distance between start and end point to consider a path closed" 98 | "(default: 0.01mm)", 99 | ) 100 | @click.option( 101 | "-k", 102 | "--keep-occulted", 103 | is_flag=True, 104 | default=False, 105 | help="Save the occulted lines on a different layer", 106 | ) 107 | @click.option( 108 | "-l", 109 | "--layer", 110 | type=vpype_cli.LayerType(accept_multiple=True), 111 | default="all", 112 | help="Target layer(s).", 113 | ) 114 | @click.option( 115 | "-i", 116 | "--ignore-layers", 117 | is_flag=True, 118 | default=False, 119 | help="Ignore layers when performing occlusion", 120 | ) 121 | @click.option( 122 | "-a", 123 | "--across-layers", 124 | is_flag=True, 125 | default=False, 126 | help="Only perform occlusion across layers. Ignore occlusions within any given layer", 127 | ) 128 | @click.option( 129 | "-r", 130 | "--reverse", 131 | is_flag=True, 132 | default=False, 133 | help="Reverse geometry order", 134 | ) 135 | @vpype_cli.global_processor 136 | def occult( 137 | document: vp.Document, 138 | tolerance: float, 139 | layer: Optional[Union[int, List[int]]], 140 | keep_occulted: bool = False, 141 | ignore_layers: bool = False, 142 | across_layers: bool = False, 143 | reverse: bool = False, 144 | ) -> vp.Document: 145 | """ 146 | Remove lines occulted by polygons. 147 | The 'keep_occulted' option (-k, --keep-occulted) saves removed geometries in a new layer. 148 | The order of the geometries in 'lines' matters, see basic example below. 149 | Occlusion is performed layer by layer. This means that if one geometry is occulting another, 150 | and these geometries are not in the same layer, occult won't remove occulted paths. 151 | With the 'ignore_layers' option, occlusion is performed on all geometry regardless 152 | of layers, with higher-numbered layers occluding lower-numbered layers. 153 | 154 | Args: 155 | document: the vpype.Document to work on. 156 | tolerance: controls the distance tolerance between the first and last points 157 | of a geometry to consider it closed. 158 | layer: specify which layer(s) to work on. Default: all. 159 | keep_occulted: If set, this flag allows to save removed lines in a separate layer. 160 | ignore_layers: If set, this flag causes occult to treat all geometries as if they 161 | exist on the same layer. However, all geometries in the final result 162 | remain on their original layer. 163 | across_layers: If set, this flag causes occult to only consider occlusions that occur 164 | across layers. If a geometry is occulted by another geometry on the same layer, it will 165 | remain unchanged. All geometries in the final result remain on their original layer. Overrides 166 | the 'ignore_layers' option. 167 | 168 | Examples: 169 | 170 | - Basic usage: 171 | $ vpype line 0 0 5cm 5cm rect 2cm 2cm 1cm 1cm occult show # line is occulted by rect 172 | $ vpype rect 2cm 2cm 1cm 1cm line 0 0 5cm 5cm occult show # line is NOT occulted by rect, 173 | as the line is drawn after the rectangle. 174 | 175 | - Keep occulted lines in a separate layer: 176 | $ vpype line -- -3cm 0 8cm 0 circle 0 0 3cm circle -l 2 5cm 0 3cm occult -k show 177 | # 'occult -k' will remove the path inside the first circle, and put it in a third layer. 178 | # both the first circle and the line are not affected by the second circle, as it is 179 | # in a different layer. 180 | """ 181 | new_document = document.empty_copy(keep_layers=True) 182 | layer_ids = vpype_cli.multiple_to_layer_ids(layer, document) 183 | removed_layer_id = document.free_id() 184 | all_layers = document.layers 185 | 186 | if ignore_layers or across_layers: 187 | active_layers = [{l_id: list(document.layers_from_ids([l_id]))[0] for l_id in layer_ids}] 188 | else: 189 | active_layers = [{l_id: list(document.layers_from_ids([l_id]))[0]} for l_id in layer_ids] 190 | 191 | if reverse: 192 | for layer in active_layers: 193 | for key in layer: 194 | layer[key].reverse() 195 | 196 | for layer in active_layers: 197 | lines, removed_lines = _occult_layer(layer, tolerance, keep_occulted, across_layers) 198 | 199 | for l_id, occulted_lines in lines.items(): 200 | new_document.add(occulted_lines, layer_id=l_id) 201 | 202 | if keep_occulted and not removed_lines.is_empty(): 203 | new_document.add(removed_lines, layer_id=removed_layer_id) 204 | 205 | for l_id, lines in all_layers.items(): 206 | if l_id not in layer_ids: 207 | new_document.add(lines, layer_id=l_id) 208 | 209 | return new_document 210 | 211 | 212 | occult.help_group = "Plugins" 213 | --------------------------------------------------------------------------------