├── 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 |
--------------------------------------------------------------------------------