deoverlap 3.2.0__tar.gz → 3.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deoverlap
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: De-overlap Shapely geometries that sit within a tolerance of each other.
5
5
  Author-email: Pietro Leoni <pietro.leoni@gmail.com>
6
6
  License: MIT License
@@ -51,12 +51,14 @@ Dynamic: license-file
51
51
 
52
52
  De-overlap Shapely geometries that sit within a tolerance of each other.
53
53
 
54
+ Source, examples and issues: [github.com/piLeoni/deoverlap](https://github.com/piLeoni/deoverlap)
55
+
54
56
  The common case is pen-plotter work: two strokes closer than a pen width
55
57
  visually merge on paper, so only one of them should keep the ink. Unlike
56
58
  endpoint-only “deduplicate” tools, this library builds a **corridor** around
57
59
  each kept stroke and crops (or drops) later strokes that fall inside it.
58
60
 
59
- ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](docs/img/hero.png)
61
+ ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/hero.png)
60
62
 
61
63
  In every figure, blue is kept, red is removed and the thin orange outline is
62
64
  the corridor mask. They are rendered by `examples/make_figures.py`.
@@ -96,7 +98,7 @@ When two corridors collide, priority is explicit:
96
98
  | `longest` | Prefer the stroke that covers more ground |
97
99
  | `shortest` | Prefer short marks / detail |
98
100
 
99
- ![The same three strokes under keep=first, longest and shortest](docs/img/keep_policy.png)
101
+ ![The same three strokes under keep=first, longest and shortest](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/keep_policy.png)
100
102
 
101
103
  Original indices are preserved in `result.kept_parts` / `removed_parts` even
102
104
  when processing order changes.
@@ -107,7 +109,7 @@ when processing order changes.
107
109
  - `mode="drop"` — if more than `drop_fraction` (default 0.5) of a geometry’s
108
110
  length is covered, discard the whole thing instead of leaving stubs.
109
111
 
110
- ![crop keeps the protruding stub, drop discards the mostly covered stroke](docs/img/crop_vs_drop.png)
112
+ ![crop keeps the protruding stub, drop discards the mostly covered stroke](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/crop_vs_drop.png)
111
113
 
112
114
  `min_length=` drops lineal fragments shorter than that threshold after clipping
113
115
  (points are never removed by it).
@@ -138,7 +140,7 @@ whatever direction its two ends point in.
138
140
  Raising it to 45° or 60° trims steeper merges too. Crossings steeper than the
139
141
  threshold are never cut.
140
142
 
141
- ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](docs/img/parallel_only.png)
143
+ ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/parallel_only.png)
142
144
 
143
145
  ## Groups — split pieces stay one object
144
146
 
@@ -157,7 +159,7 @@ result.kept_parts[1] # MultiLineString of both arcs
157
159
  Set `group=False` for a flat list of primitive pieces (origins still recorded
158
160
  in `kept_parts`).
159
161
 
160
- ![A ring cut by a line: one grouped result versus separate arcs](docs/img/groups.png)
162
+ ![A ring cut by a line: one grouped result versus separate arcs](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/groups.png)
161
163
 
162
164
  This is the right model for layer-aware pipelines too: treat each input
163
165
  geometry as a group, run deoverlap per layer, and never let one layer’s
@@ -280,7 +282,7 @@ Cutting leaves fragments; almost every piece under 1 mm on this card is one.
280
282
  to 860 and the drawn length drops by 35%. For the more conservative default
281
283
  (`--parallel-angle 30`, crossings left alone), the same card loses about 21%.
282
284
 
283
- ![The whole card: removed ink in red, the dashed box is the zoom below](docs/img/osm_map.png)
285
+ ![The whole card: removed ink in red, the dashed box is the zoom below](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_map.png)
284
286
 
285
287
  In the zoom, the ink panels are blended like real ink: every pass multiplies
286
288
  the colour, so the darker the blue, the more times the pen went over the same
@@ -288,7 +290,7 @@ spot. Before, the dark bands are doubled carriageways and the dark dots are
288
290
  junctions, where a round pen tip lands on ink that is already there. After,
289
291
  the ink is one even layer.
290
292
 
291
- ![Zoom at pen width: before, what was removed, after](docs/img/osm_zoom.png)
293
+ ![Zoom at pen width: before, what was removed, after](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_zoom.png)
292
294
 
293
295
  To try another place (needs `pip install osmnx`):
294
296
 
@@ -320,7 +322,7 @@ its own corridor unit; opposite sides can suppress each other, while immediate
320
322
  neighbours on the same chain (`--segment-adjacency`, default 1) stay intact so
321
323
  joints are not nibbled.
322
324
 
323
- ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](docs/img/segments.png)
325
+ ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/segments.png)
324
326
 
325
327
  ```bash
326
328
  vpype read map.svg deoverlap -t 0.15mm --keep longest --segments -l 1 write out.svg
@@ -2,12 +2,14 @@
2
2
 
3
3
  De-overlap Shapely geometries that sit within a tolerance of each other.
4
4
 
5
+ Source, examples and issues: [github.com/piLeoni/deoverlap](https://github.com/piLeoni/deoverlap)
6
+
5
7
  The common case is pen-plotter work: two strokes closer than a pen width
6
8
  visually merge on paper, so only one of them should keep the ink. Unlike
7
9
  endpoint-only “deduplicate” tools, this library builds a **corridor** around
8
10
  each kept stroke and crops (or drops) later strokes that fall inside it.
9
11
 
10
- ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](docs/img/hero.png)
12
+ ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/hero.png)
11
13
 
12
14
  In every figure, blue is kept, red is removed and the thin orange outline is
13
15
  the corridor mask. They are rendered by `examples/make_figures.py`.
@@ -47,7 +49,7 @@ When two corridors collide, priority is explicit:
47
49
  | `longest` | Prefer the stroke that covers more ground |
48
50
  | `shortest` | Prefer short marks / detail |
49
51
 
50
- ![The same three strokes under keep=first, longest and shortest](docs/img/keep_policy.png)
52
+ ![The same three strokes under keep=first, longest and shortest](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/keep_policy.png)
51
53
 
52
54
  Original indices are preserved in `result.kept_parts` / `removed_parts` even
53
55
  when processing order changes.
@@ -58,7 +60,7 @@ when processing order changes.
58
60
  - `mode="drop"` — if more than `drop_fraction` (default 0.5) of a geometry’s
59
61
  length is covered, discard the whole thing instead of leaving stubs.
60
62
 
61
- ![crop keeps the protruding stub, drop discards the mostly covered stroke](docs/img/crop_vs_drop.png)
63
+ ![crop keeps the protruding stub, drop discards the mostly covered stroke](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/crop_vs_drop.png)
62
64
 
63
65
  `min_length=` drops lineal fragments shorter than that threshold after clipping
64
66
  (points are never removed by it).
@@ -89,7 +91,7 @@ whatever direction its two ends point in.
89
91
  Raising it to 45° or 60° trims steeper merges too. Crossings steeper than the
90
92
  threshold are never cut.
91
93
 
92
- ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](docs/img/parallel_only.png)
94
+ ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/parallel_only.png)
93
95
 
94
96
  ## Groups — split pieces stay one object
95
97
 
@@ -108,7 +110,7 @@ result.kept_parts[1] # MultiLineString of both arcs
108
110
  Set `group=False` for a flat list of primitive pieces (origins still recorded
109
111
  in `kept_parts`).
110
112
 
111
- ![A ring cut by a line: one grouped result versus separate arcs](docs/img/groups.png)
113
+ ![A ring cut by a line: one grouped result versus separate arcs](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/groups.png)
112
114
 
113
115
  This is the right model for layer-aware pipelines too: treat each input
114
116
  geometry as a group, run deoverlap per layer, and never let one layer’s
@@ -231,7 +233,7 @@ Cutting leaves fragments; almost every piece under 1 mm on this card is one.
231
233
  to 860 and the drawn length drops by 35%. For the more conservative default
232
234
  (`--parallel-angle 30`, crossings left alone), the same card loses about 21%.
233
235
 
234
- ![The whole card: removed ink in red, the dashed box is the zoom below](docs/img/osm_map.png)
236
+ ![The whole card: removed ink in red, the dashed box is the zoom below](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_map.png)
235
237
 
236
238
  In the zoom, the ink panels are blended like real ink: every pass multiplies
237
239
  the colour, so the darker the blue, the more times the pen went over the same
@@ -239,7 +241,7 @@ spot. Before, the dark bands are doubled carriageways and the dark dots are
239
241
  junctions, where a round pen tip lands on ink that is already there. After,
240
242
  the ink is one even layer.
241
243
 
242
- ![Zoom at pen width: before, what was removed, after](docs/img/osm_zoom.png)
244
+ ![Zoom at pen width: before, what was removed, after](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_zoom.png)
243
245
 
244
246
  To try another place (needs `pip install osmnx`):
245
247
 
@@ -271,7 +273,7 @@ its own corridor unit; opposite sides can suppress each other, while immediate
271
273
  neighbours on the same chain (`--segment-adjacency`, default 1) stay intact so
272
274
  joints are not nibbled.
273
275
 
274
- ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](docs/img/segments.png)
276
+ ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/segments.png)
275
277
 
276
278
  ```bash
277
279
  vpype read map.svg deoverlap -t 0.15mm --keep longest --segments -l 1 write out.svg
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "deoverlap"
9
- version = "3.2.0"
9
+ version = "3.2.2"
10
10
  authors = [
11
11
  { name="Pietro Leoni", email="pietro.leoni@gmail.com" },
12
12
  ]
@@ -1,6 +1,6 @@
1
1
  """De-overlap Shapely geometries that sit within a tolerance of each other."""
2
2
 
3
- __version__ = "3.2.0"
3
+ __version__ = "3.2.2"
4
4
 
5
5
  from .deoverlap import (
6
6
  ClipMode,
@@ -69,6 +69,7 @@ class _SegId:
69
69
  index: int
70
70
  count: int
71
71
  closed: bool
72
+ heading: float = 0.0 # direction of travel, radians
72
73
 
73
74
 
74
75
  @dataclass
@@ -189,6 +190,17 @@ def _seg_adjacent(a: _SegId, b: _SegId, window: int) -> bool:
189
190
  return False
190
191
 
191
192
 
193
+ def _heading(a: tuple[float, float], b: tuple[float, float]) -> float:
194
+ return math.atan2(b[1] - a[1], b[0] - a[0])
195
+
196
+
197
+ def _folds_back(a: _SegId, b: _SegId, angle_tol_rad: float) -> bool:
198
+ """True if the chain doubles back: headings nearly opposite (a hairpin)."""
199
+ d = abs(a.heading - b.heading) % (2 * math.pi)
200
+ d = min(d, 2 * math.pi - d)
201
+ return d > math.pi - angle_tol_rad
202
+
203
+
192
204
  def _reassemble(parts: Sequence[BaseGeometry], original: BaseGeometry) -> BaseGeometry:
193
205
  clean = [p for p in parts if p is not None and not p.is_empty]
194
206
  if not clean:
@@ -200,6 +212,10 @@ def _reassemble(parts: Sequence[BaseGeometry], original: BaseGeometry) -> BaseGe
200
212
  and merged.equals(original.boundary)
201
213
  ):
202
214
  return original
215
+ if merged.geom_type == "MultiLineString":
216
+ # Rejoin pieces that meet end to end: the arc through a ring's start
217
+ # vertex, or consecutive edges in segments mode.
218
+ merged = line_merge(merged)
203
219
  return merged
204
220
 
205
221
 
@@ -285,7 +301,7 @@ def _explode_segments(
285
301
  continue
286
302
  segments.append(LineString([a, b]))
287
303
  origins.append(origin)
288
- seg_ids.append(_SegId(chain, i, count, True))
304
+ seg_ids.append(_SegId(chain, i, count, True, _heading(a, b)))
289
305
  parent_lengths.append(parent_len)
290
306
  else:
291
307
  count = len(coords) - 1
@@ -295,7 +311,7 @@ def _explode_segments(
295
311
  continue
296
312
  segments.append(LineString([a, b]))
297
313
  origins.append(origin)
298
- seg_ids.append(_SegId(chain, i, count, False))
314
+ seg_ids.append(_SegId(chain, i, count, False, _heading(a, b)))
299
315
  parent_lengths.append(parent_len)
300
316
  chain += 1
301
317
 
@@ -407,6 +423,7 @@ class _MaskIndex:
407
423
  seg_id is not None
408
424
  and other_seg is not None
409
425
  and _seg_adjacent(seg_id, other_seg, self.segment_adjacency)
426
+ and not _folds_back(seg_id, other_seg, angle_tol_rad)
410
427
  ):
411
428
  continue
412
429
  if self.parallel_only and angle is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deoverlap
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: De-overlap Shapely geometries that sit within a tolerance of each other.
5
5
  Author-email: Pietro Leoni <pietro.leoni@gmail.com>
6
6
  License: MIT License
@@ -51,12 +51,14 @@ Dynamic: license-file
51
51
 
52
52
  De-overlap Shapely geometries that sit within a tolerance of each other.
53
53
 
54
+ Source, examples and issues: [github.com/piLeoni/deoverlap](https://github.com/piLeoni/deoverlap)
55
+
54
56
  The common case is pen-plotter work: two strokes closer than a pen width
55
57
  visually merge on paper, so only one of them should keep the ink. Unlike
56
58
  endpoint-only “deduplicate” tools, this library builds a **corridor** around
57
59
  each kept stroke and crops (or drops) later strokes that fall inside it.
58
60
 
59
- ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](docs/img/hero.png)
61
+ ![Tangent circles and a line: the corridor mask (orange) around kept strokes (blue) crops the overlapping arcs (red)](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/hero.png)
60
62
 
61
63
  In every figure, blue is kept, red is removed and the thin orange outline is
62
64
  the corridor mask. They are rendered by `examples/make_figures.py`.
@@ -96,7 +98,7 @@ When two corridors collide, priority is explicit:
96
98
  | `longest` | Prefer the stroke that covers more ground |
97
99
  | `shortest` | Prefer short marks / detail |
98
100
 
99
- ![The same three strokes under keep=first, longest and shortest](docs/img/keep_policy.png)
101
+ ![The same three strokes under keep=first, longest and shortest](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/keep_policy.png)
100
102
 
101
103
  Original indices are preserved in `result.kept_parts` / `removed_parts` even
102
104
  when processing order changes.
@@ -107,7 +109,7 @@ when processing order changes.
107
109
  - `mode="drop"` — if more than `drop_fraction` (default 0.5) of a geometry’s
108
110
  length is covered, discard the whole thing instead of leaving stubs.
109
111
 
110
- ![crop keeps the protruding stub, drop discards the mostly covered stroke](docs/img/crop_vs_drop.png)
112
+ ![crop keeps the protruding stub, drop discards the mostly covered stroke](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/crop_vs_drop.png)
111
113
 
112
114
  `min_length=` drops lineal fragments shorter than that threshold after clipping
113
115
  (points are never removed by it).
@@ -138,7 +140,7 @@ whatever direction its two ends point in.
138
140
  Raising it to 45° or 60° trims steeper merges too. Crossings steeper than the
139
141
  threshold are never cut.
140
142
 
141
- ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](docs/img/parallel_only.png)
143
+ ![Without parallel_only the crossings get punched; with it only the parallel duplicate is cropped](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/parallel_only.png)
142
144
 
143
145
  ## Groups — split pieces stay one object
144
146
 
@@ -157,7 +159,7 @@ result.kept_parts[1] # MultiLineString of both arcs
157
159
  Set `group=False` for a flat list of primitive pieces (origins still recorded
158
160
  in `kept_parts`).
159
161
 
160
- ![A ring cut by a line: one grouped result versus separate arcs](docs/img/groups.png)
162
+ ![A ring cut by a line: one grouped result versus separate arcs](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/groups.png)
161
163
 
162
164
  This is the right model for layer-aware pipelines too: treat each input
163
165
  geometry as a group, run deoverlap per layer, and never let one layer’s
@@ -280,7 +282,7 @@ Cutting leaves fragments; almost every piece under 1 mm on this card is one.
280
282
  to 860 and the drawn length drops by 35%. For the more conservative default
281
283
  (`--parallel-angle 30`, crossings left alone), the same card loses about 21%.
282
284
 
283
- ![The whole card: removed ink in red, the dashed box is the zoom below](docs/img/osm_map.png)
285
+ ![The whole card: removed ink in red, the dashed box is the zoom below](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_map.png)
284
286
 
285
287
  In the zoom, the ink panels are blended like real ink: every pass multiplies
286
288
  the colour, so the darker the blue, the more times the pen went over the same
@@ -288,7 +290,7 @@ spot. Before, the dark bands are doubled carriageways and the dark dots are
288
290
  junctions, where a round pen tip lands on ink that is already there. After,
289
291
  the ink is one even layer.
290
292
 
291
- ![Zoom at pen width: before, what was removed, after](docs/img/osm_zoom.png)
293
+ ![Zoom at pen width: before, what was removed, after](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/osm_zoom.png)
292
294
 
293
295
  To try another place (needs `pip install osmnx`):
294
296
 
@@ -320,7 +322,7 @@ its own corridor unit; opposite sides can suppress each other, while immediate
320
322
  neighbours on the same chain (`--segment-adjacency`, default 1) stay intact so
321
323
  joints are not nibbled.
322
324
 
323
- ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](docs/img/segments.png)
325
+ ![A thin ribbon drawn as one polyline: untouched without segments, one side suppressed with segments](https://raw.githubusercontent.com/piLeoni/deoverlap/main/docs/img/segments.png)
324
326
 
325
327
  ```bash
326
328
  vpype read map.svg deoverlap -t 0.15mm --keep longest --segments -l 1 write out.svg
@@ -108,6 +108,35 @@ def test_group_keeps_split_ring_as_one_multipart():
108
108
  assert len(flat_result.kept) >= 2
109
109
 
110
110
 
111
+ def test_cut_ring_joins_across_its_start_point():
112
+ """The arc through the ring's first/last vertex is one piece, not two."""
113
+ ring = LineString([(0, 0), (2, 0), (2, 2), (0, 2), (0, 0)])
114
+ cutter = LineString([(1, -1), (1, 3)])
115
+ grouped = deoverlap([cutter, ring], 0.15, group=True, keep=KeepPolicy.FIRST)
116
+ assert len(flatten_geometries(grouped.kept_parts[1])) == 2
117
+ flat = deoverlap([cutter, ring], 0.15, group=False, keep=KeepPolicy.FIRST)
118
+ assert len(flat.kept) == 3 # cutter + two arcs
119
+
120
+
121
+ def test_segments_removes_fold_back_between_neighbours():
122
+ """A hairpin folds onto itself; neighbouring edges must still suppress."""
123
+ spike = LineString([(0, 0), (6, 0), (6.5, 0.4), (6, 0.08), (0, 0.08), (0, 0)])
124
+ result = deoverlap(
125
+ [spike], 0.1, segments=True, parallel_only=True, keep="first",
126
+ min_length=0.05, keep_duplicates=True,
127
+ )
128
+ kept = result.kept_parts[0]
129
+ back = LineString([(6.5, 0.4), (6, 0.08)])
130
+ assert kept.intersection(back.buffer(0.01)).length < 0.1
131
+
132
+
133
+ def test_segments_keeps_ordinary_corners():
134
+ """A 90 degree corner between neighbours is not a fold-back."""
135
+ square = LineString([(0, 0), (2, 0), (2, 2), (0, 2), (0, 0)])
136
+ result = deoverlap([square], 0.3, segments=True, parallel_only=True)
137
+ assert result.kept_parts[0].length == pytest.approx(square.length, abs=0.05)
138
+
139
+
111
140
  def test_parallel_only_preserves_crossing():
112
141
  horizontal = LineString([(0, 0), (4, 0)])
113
142
  vertical = LineString([(2, -2), (2, 2)])
File without changes
File without changes