smoothify 0.2.1__tar.gz → 0.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.
Files changed (25) hide show
  1. {smoothify-0.2.1/smoothify.egg-info → smoothify-0.2.2}/PKG-INFO +1 -1
  2. smoothify-0.2.2/smoothify/__version__.py +1 -0
  3. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify/smoothify_core.py +19 -10
  4. {smoothify-0.2.1 → smoothify-0.2.2/smoothify.egg-info}/PKG-INFO +1 -1
  5. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify.egg-info/SOURCES.txt +1 -0
  6. smoothify-0.2.2/tests/test_all_geometry_types.py +894 -0
  7. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_smoothify_core.py +20 -0
  8. smoothify-0.2.1/smoothify/__version__.py +0 -1
  9. {smoothify-0.2.1 → smoothify-0.2.2}/LICENSE +0 -0
  10. {smoothify-0.2.1 → smoothify-0.2.2}/README.md +0 -0
  11. {smoothify-0.2.1 → smoothify-0.2.2}/pyproject.toml +0 -0
  12. {smoothify-0.2.1 → smoothify-0.2.2}/setup.cfg +0 -0
  13. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify/__init__.py +0 -0
  14. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify/coordinator.py +0 -0
  15. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify/geometry_ops.py +0 -0
  16. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify.egg-info/dependency_links.txt +0 -0
  17. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify.egg-info/requires.txt +0 -0
  18. {smoothify-0.2.1 → smoothify-0.2.2}/smoothify.egg-info/top_level.txt +0 -0
  19. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_area_tolerance.py +0 -0
  20. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_auto_segment_length.py +0 -0
  21. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_chaikin.py +0 -0
  22. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_edge_cases_coverage.py +0 -0
  23. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_geometry_types.py +0 -0
  24. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_real_world_data.py +0 -0
  25. {smoothify-0.2.1 → smoothify-0.2.2}/tests/test_smoothify_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smoothify
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Transform pixelated geometries from raster data into smooth natural looking features
5
5
  Author-email: Nick Wright <nicholas.wright@dpird.wa.gov.au>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -52,7 +52,7 @@ def _chaikin_corner_cutting(
52
52
  # Vectorized smoothing at 1/4 and 3/4 positions
53
53
  # Pre-allocate result array for better performance
54
54
  n_new_points = len(p0) * 2
55
- points = np.empty((n_new_points, 2), dtype=np.float64)
55
+ points = np.empty((n_new_points, points.shape[1]), dtype=np.float64)
56
56
  points[0::2] = 0.75 * p0 + 0.25 * p1 # q points
57
57
  points[1::2] = 0.25 * p0 + 0.75 * p1 # r points
58
58
 
@@ -306,17 +306,26 @@ def _smoothify_geometry(
306
306
  )
307
307
  geom_iterations.append(smoothed)
308
308
 
309
- geom_iterations = [make_valid(g) for g in geom_iterations]
309
+ if isinstance(geom, Polygon):
310
+ geom_iterations = [make_valid(g) for g in geom_iterations]
310
311
 
311
- dissolved_poly = make_valid(unary_union(geom_iterations)).simplify(
312
- tolerance=segment_length / 5,
313
- preserve_topology=True,
314
- )
312
+ dissolved_poly = make_valid(unary_union(geom_iterations)).simplify(
313
+ tolerance=segment_length / 5,
314
+ preserve_topology=True,
315
+ )
315
316
 
316
- # If the union is a MultiPolygon, take the largest geometry
317
- if isinstance(dissolved_poly, MultiPolygon):
318
- largest_geom = max(dissolved_poly.geoms, key=lambda x: x.area)
319
- dissolved_poly = largest_geom
317
+ # If the union is a MultiPolygon, take the largest geometry
318
+ if isinstance(dissolved_poly, MultiPolygon):
319
+ largest_geom = max(dissolved_poly.geoms, key=lambda x: x.area)
320
+ dissolved_poly = largest_geom
321
+ else:
322
+ # LineStrings: skip make_valid/unary_union — self-intersecting lines
323
+ # are geometrically valid, and unary_union would split them at
324
+ # crossing points into a MultiLineString
325
+ dissolved_poly = geom_iterations[0].simplify(
326
+ tolerance=segment_length / 5,
327
+ preserve_topology=True,
328
+ )
320
329
 
321
330
  assert isinstance(dissolved_poly, (Polygon, LineString)), (
322
331
  f"Resulting geometry must be Polygon or LineString. Got {type(dissolved_poly)}."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smoothify
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Transform pixelated geometries from raster data into smooth natural looking features
5
5
  Author-email: Nick Wright <nicholas.wright@dpird.wa.gov.au>
6
6
  License-Expression: MIT
@@ -11,6 +11,7 @@ smoothify.egg-info/SOURCES.txt
11
11
  smoothify.egg-info/dependency_links.txt
12
12
  smoothify.egg-info/requires.txt
13
13
  smoothify.egg-info/top_level.txt
14
+ tests/test_all_geometry_types.py
14
15
  tests/test_area_tolerance.py
15
16
  tests/test_auto_segment_length.py
16
17
  tests/test_chaikin.py
@@ -0,0 +1,894 @@
1
+ """Tests for all geometry types with simple and complex shapes.
2
+
3
+ Ensures smoothify handles every supported geometry type correctly,
4
+ from minimal examples through to complex real-world-like shapes.
5
+ """
6
+
7
+ import math
8
+
9
+ import numpy as np
10
+ import pytest
11
+ from shapely import make_valid, wkt
12
+ from shapely.geometry import (
13
+ GeometryCollection,
14
+ LinearRing,
15
+ LineString,
16
+ MultiLineString,
17
+ MultiPolygon,
18
+ Polygon,
19
+ )
20
+
21
+ from smoothify import smoothify
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Polygons
26
+ # ---------------------------------------------------------------------------
27
+ class TestPolygonSimple:
28
+ """Simple polygon inputs."""
29
+
30
+ def test_triangle(self):
31
+ poly = Polygon([(0, 0), (10, 0), (5, 8)])
32
+ result = smoothify(poly, segment_length=1.0)
33
+ assert isinstance(result, Polygon)
34
+ assert result.is_valid
35
+ assert len(result.exterior.coords) > 3
36
+
37
+ def test_square(self):
38
+ poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
39
+ result = smoothify(poly, segment_length=1.0)
40
+ assert isinstance(result, Polygon)
41
+ assert result.is_valid
42
+
43
+ def test_rectangle_thin(self):
44
+ """Thin rectangle — smoothing shouldn't collapse it."""
45
+ poly = Polygon([(0, 0), (100, 0), (100, 2), (0, 2)])
46
+ result = smoothify(poly, segment_length=1.0)
47
+ assert isinstance(result, Polygon)
48
+ assert result.is_valid
49
+ assert result.area > 0
50
+
51
+ def test_regular_hexagon(self):
52
+ coords = [
53
+ (10 * math.cos(math.radians(60 * i)), 10 * math.sin(math.radians(60 * i)))
54
+ for i in range(6)
55
+ ]
56
+ poly = Polygon(coords)
57
+ result = smoothify(poly, segment_length=1.0)
58
+ assert isinstance(result, Polygon)
59
+ assert result.is_valid
60
+
61
+
62
+ class TestPolygonComplex:
63
+ """Complex polygon inputs."""
64
+
65
+ def test_star_shape(self):
66
+ """Star polygon with concavities."""
67
+ coords = []
68
+ for i in range(10):
69
+ angle = math.radians(36 * i)
70
+ r = 10 if i % 2 == 0 else 4
71
+ coords.append((r * math.cos(angle), r * math.sin(angle)))
72
+ poly = Polygon(coords)
73
+ result = smoothify(poly, segment_length=0.5)
74
+ assert isinstance(result, Polygon)
75
+ assert result.is_valid
76
+
77
+ def test_polygon_with_one_hole(self):
78
+ exterior = [(0, 0), (50, 0), (50, 50), (0, 50)]
79
+ hole = [(15, 15), (35, 15), (35, 35), (15, 35)]
80
+ poly = Polygon(exterior, [hole])
81
+ result = smoothify(poly, segment_length=1.0)
82
+ assert isinstance(result, (Polygon, MultiPolygon))
83
+ assert result.is_valid
84
+
85
+ def test_polygon_with_many_holes(self):
86
+ exterior = [(0, 0), (100, 0), (100, 100), (0, 100)]
87
+ holes = [
88
+ [(10 + 25 * i, 10 + 25 * j),
89
+ (20 + 25 * i, 10 + 25 * j),
90
+ (20 + 25 * i, 20 + 25 * j),
91
+ (10 + 25 * i, 20 + 25 * j)]
92
+ for i in range(3) for j in range(3)
93
+ ]
94
+ poly = Polygon(exterior, holes)
95
+ result = smoothify(poly, segment_length=1.0)
96
+ assert result.is_valid
97
+
98
+ def test_circle_approximation(self):
99
+ """High-vertex polygon approximating a circle."""
100
+ n = 200
101
+ coords = [
102
+ (10 * math.cos(2 * math.pi * i / n),
103
+ 10 * math.sin(2 * math.pi * i / n))
104
+ for i in range(n)
105
+ ]
106
+ poly = Polygon(coords)
107
+ result = smoothify(poly, segment_length=0.5)
108
+ assert isinstance(result, Polygon)
109
+ assert result.is_valid
110
+
111
+ def test_L_shaped_polygon(self):
112
+ poly = Polygon([
113
+ (0, 0), (10, 0), (10, 5), (5, 5), (5, 10), (0, 10),
114
+ ])
115
+ result = smoothify(poly, segment_length=0.5)
116
+ assert isinstance(result, Polygon)
117
+ assert result.is_valid
118
+
119
+ def test_pixelated_polygon(self):
120
+ """Staircase polygon typical of raster-to-vector conversion."""
121
+ coords = []
122
+ for i in range(10):
123
+ coords.append((i, i))
124
+ coords.append((i + 1, i))
125
+ for i in range(10, 0, -1):
126
+ coords.append((i, i + 5))
127
+ coords.append((i - 1, i + 5))
128
+ poly = Polygon(coords)
129
+ result = smoothify(poly, segment_length=1.0)
130
+ assert isinstance(result, Polygon)
131
+ assert result.is_valid
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # LineStrings
136
+ # ---------------------------------------------------------------------------
137
+ class TestLineStringSimple:
138
+ """Simple LineString inputs."""
139
+
140
+ def test_two_point_line(self):
141
+ line = LineString([(0, 0), (10, 10)])
142
+ result = smoothify(line, segment_length=1.0)
143
+ assert isinstance(result, LineString)
144
+ assert result.coords[0] == (0.0, 0.0)
145
+ assert result.coords[-1] == (10.0, 10.0)
146
+
147
+ def test_three_point_angle(self):
148
+ line = LineString([(0, 0), (5, 5), (10, 0)])
149
+ result = smoothify(line, segment_length=1.0)
150
+ assert isinstance(result, LineString)
151
+
152
+ def test_straight_line(self):
153
+ """Perfectly straight line should remain straight-ish."""
154
+ line = LineString([(0, 0), (5, 0), (10, 0)])
155
+ result = smoothify(line, segment_length=1.0)
156
+ assert isinstance(result, LineString)
157
+
158
+ def test_right_angle(self):
159
+ line = LineString([(0, 0), (10, 0), (10, 10)])
160
+ result = smoothify(line, segment_length=1.0)
161
+ assert isinstance(result, LineString)
162
+
163
+
164
+ class TestLineStringComplex:
165
+ """Complex LineString inputs."""
166
+
167
+ def test_zigzag(self):
168
+ coords = [(i, (i % 2) * 5) for i in range(20)]
169
+ line = LineString(coords)
170
+ result = smoothify(line, segment_length=0.5)
171
+ assert isinstance(result, LineString)
172
+ assert result.coords[0] == line.coords[0]
173
+ assert result.coords[-1] == line.coords[-1]
174
+
175
+ def test_spiral(self):
176
+ t = np.linspace(0, 4 * np.pi, 200)
177
+ x = t * np.cos(t)
178
+ y = t * np.sin(t)
179
+ line = LineString(np.column_stack([x, y]))
180
+ result = smoothify(line, segment_length=0.5)
181
+ assert isinstance(result, LineString)
182
+
183
+ def test_self_intersecting_line(self):
184
+ """Self-intersecting line must stay a LineString, not be split."""
185
+ line = LineString([
186
+ (0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
187
+ (2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
188
+ ])
189
+ assert not line.is_simple
190
+ result = smoothify(line, segment_length=0.5)
191
+ assert isinstance(result, LineString)
192
+
193
+ def test_random_walk(self):
194
+ """Long random-walk line (river-like)."""
195
+ rng = np.random.default_rng(42)
196
+ n = 300
197
+ x = np.cumsum(rng.standard_normal(n) * 0.2)
198
+ y = np.cumsum(rng.standard_normal(n) * 0.2)
199
+ line = LineString(np.column_stack([x, y]))
200
+ result = smoothify(line, segment_length=0.5)
201
+ assert isinstance(result, LineString)
202
+
203
+ def test_many_vertices(self):
204
+ coords = [(i * 0.1, math.sin(i * 0.1) * 5) for i in range(500)]
205
+ line = LineString(coords)
206
+ result = smoothify(line, segment_length=0.5)
207
+ assert isinstance(result, LineString)
208
+
209
+ def test_hairpin_turn(self):
210
+ """Tight U-turn."""
211
+ line = LineString([(0, 0), (10, 0), (10.5, 0.5), (10, 1), (0, 1)])
212
+ result = smoothify(line, segment_length=0.5)
213
+ assert isinstance(result, LineString)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # LinearRings
218
+ # ---------------------------------------------------------------------------
219
+ class TestLinearRing:
220
+ """LinearRing inputs."""
221
+
222
+ def test_simple_ring(self):
223
+ ring = LinearRing([(0, 0), (10, 0), (10, 10), (0, 10)])
224
+ result = smoothify(ring, segment_length=1.0)
225
+ assert isinstance(result, (LinearRing, Polygon))
226
+ assert result.is_valid
227
+
228
+ def test_triangular_ring(self):
229
+ ring = LinearRing([(0, 0), (10, 0), (5, 8)])
230
+ result = smoothify(ring, segment_length=1.0)
231
+ assert isinstance(result, (LinearRing, Polygon))
232
+ assert result.is_valid
233
+
234
+ def test_complex_ring(self):
235
+ """Ring with many vertices."""
236
+ n = 50
237
+ coords = [
238
+ (10 * math.cos(2 * math.pi * i / n),
239
+ 10 * math.sin(2 * math.pi * i / n))
240
+ for i in range(n)
241
+ ]
242
+ ring = LinearRing(coords)
243
+ result = smoothify(ring, segment_length=0.5)
244
+ assert isinstance(result, (LinearRing, Polygon))
245
+ assert result.is_valid
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # MultiPolygons
250
+ # ---------------------------------------------------------------------------
251
+ class TestMultiPolygonSimple:
252
+ """Simple MultiPolygon inputs."""
253
+
254
+ def test_two_squares(self):
255
+ p1 = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
256
+ p2 = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
257
+ mp = MultiPolygon([p1, p2])
258
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
259
+ assert result.is_valid
260
+
261
+ def test_single_polygon_multi(self):
262
+ """MultiPolygon with one polygon."""
263
+ p = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
264
+ mp = MultiPolygon([p])
265
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
266
+ assert result.is_valid
267
+
268
+
269
+ class TestMultiPolygonComplex:
270
+ """Complex MultiPolygon inputs."""
271
+
272
+ def test_adjacent_squares_merged(self):
273
+ """Adjacent polygons that should merge."""
274
+ p1 = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
275
+ p2 = Polygon([(10, 0), (20, 0), (20, 10), (10, 10)])
276
+ mp = MultiPolygon([p1, p2])
277
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=True)
278
+ assert result.is_valid
279
+
280
+ def test_many_small_polygons(self):
281
+ """Grid of small polygons."""
282
+ polys = [
283
+ Polygon([(i * 15, j * 15), (i * 15 + 5, j * 15),
284
+ (i * 15 + 5, j * 15 + 5), (i * 15, j * 15 + 5)])
285
+ for i in range(4) for j in range(4)
286
+ ]
287
+ mp = MultiPolygon(polys)
288
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
289
+ assert result.is_valid
290
+
291
+ def test_mixed_size_polygons(self):
292
+ tiny = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
293
+ large = Polygon([(50, 50), (150, 50), (150, 150), (50, 150)])
294
+ mp = MultiPolygon([tiny, large])
295
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
296
+ assert result.is_valid
297
+
298
+ def test_polygon_with_holes_in_multi(self):
299
+ exterior1 = [(0, 0), (30, 0), (30, 30), (0, 30)]
300
+ hole1 = [(5, 5), (15, 5), (15, 15), (5, 15)]
301
+ p1 = Polygon(exterior1, [hole1])
302
+ p2 = Polygon([(50, 50), (60, 50), (60, 60), (50, 60)])
303
+ mp = MultiPolygon([p1, p2])
304
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
305
+ assert result.is_valid
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # MultiLineStrings
310
+ # ---------------------------------------------------------------------------
311
+ class TestMultiLineStringSimple:
312
+ """Simple MultiLineString inputs."""
313
+
314
+ def test_two_lines(self):
315
+ ml = MultiLineString([
316
+ [(0, 0), (5, 5)],
317
+ [(10, 0), (15, 5)],
318
+ ])
319
+ result = smoothify(ml, segment_length=1.0)
320
+ assert result.is_valid
321
+
322
+ def test_single_line_multi(self):
323
+ ml = MultiLineString([[(0, 0), (5, 5), (10, 0)]])
324
+ result = smoothify(ml, segment_length=1.0)
325
+ assert result.is_valid
326
+
327
+
328
+ class TestMultiLineStringComplex:
329
+ """Complex MultiLineString inputs."""
330
+
331
+ def test_many_lines(self):
332
+ lines = [
333
+ [(i * 10, 0), (i * 10 + 5, 5), (i * 10 + 10, 0)]
334
+ for i in range(10)
335
+ ]
336
+ ml = MultiLineString(lines)
337
+ result = smoothify(ml, segment_length=1.0)
338
+ assert result.is_valid
339
+
340
+ def test_zigzag_lines(self):
341
+ lines = []
342
+ for j in range(5):
343
+ coords = [(i, (i % 2) * 3 + j * 10) for i in range(15)]
344
+ lines.append(coords)
345
+ ml = MultiLineString(lines)
346
+ result = smoothify(ml, segment_length=0.5)
347
+ assert result.is_valid
348
+
349
+ def test_long_and_short_lines(self):
350
+ short = LineString([(0, 0), (1, 1)])
351
+ long_line = LineString([(10, 10)] + [(10 + i, 10 + math.sin(i)) for i in range(100)])
352
+ ml = MultiLineString([short, long_line])
353
+ result = smoothify(ml, segment_length=1.0)
354
+ assert result.is_valid
355
+
356
+ def test_self_intersecting_multilinestring(self):
357
+ """MultiLineString containing a self-intersecting line."""
358
+ si_line = LineString([
359
+ (0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
360
+ (2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
361
+ ])
362
+ normal_line = LineString([(10, 0), (15, 5), (20, 0)])
363
+ ml = MultiLineString([si_line, normal_line])
364
+ result = smoothify(ml, segment_length=0.5)
365
+ assert result.is_valid
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # GeometryCollections
370
+ # ---------------------------------------------------------------------------
371
+ class TestGeometryCollectionSimple:
372
+ """Simple GeometryCollection inputs."""
373
+
374
+ def test_polygon_and_line(self):
375
+ poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
376
+ line = LineString([(20, 0), (30, 10)])
377
+ gc = GeometryCollection([poly, line])
378
+ result = smoothify(gc, segment_length=1.0)
379
+ assert isinstance(result, GeometryCollection)
380
+ assert result.is_valid
381
+
382
+ def test_single_polygon_collection(self):
383
+ poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
384
+ gc = GeometryCollection([poly])
385
+ result = smoothify(gc, segment_length=1.0)
386
+ assert result.is_valid
387
+
388
+
389
+ class TestGeometryCollectionComplex:
390
+ """Complex GeometryCollection inputs."""
391
+
392
+ def test_all_supported_types(self):
393
+ """Collection with every supported geometry type."""
394
+ poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
395
+ line = LineString([(20, 0), (25, 5), (30, 0)])
396
+ mp = MultiPolygon([
397
+ Polygon([(40, 0), (45, 0), (45, 5), (40, 5)]),
398
+ Polygon([(50, 0), (55, 0), (55, 5), (50, 5)]),
399
+ ])
400
+ ml = MultiLineString([
401
+ [(60, 0), (65, 5)],
402
+ [(70, 0), (75, 5)],
403
+ ])
404
+ gc = GeometryCollection([poly, line, mp, ml])
405
+ result = smoothify(gc, segment_length=1.0)
406
+ assert result.is_valid
407
+
408
+ def test_many_mixed_geometries(self):
409
+ geoms = []
410
+ for i in range(5):
411
+ geoms.append(Polygon([
412
+ (i * 20, 0), (i * 20 + 8, 0),
413
+ (i * 20 + 8, 8), (i * 20, 8),
414
+ ]))
415
+ for i in range(5):
416
+ geoms.append(LineString([
417
+ (i * 20, 20), (i * 20 + 5, 25), (i * 20 + 10, 20),
418
+ ]))
419
+ gc = GeometryCollection(geoms)
420
+ result = smoothify(gc, segment_length=1.0)
421
+ assert result.is_valid
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # List inputs
426
+ # ---------------------------------------------------------------------------
427
+ class TestListInput:
428
+ """List of geometries inputs."""
429
+
430
+ def test_list_of_polygons(self):
431
+ polys = [
432
+ Polygon([(i * 20, 0), (i * 20 + 10, 0),
433
+ (i * 20 + 10, 10), (i * 20, 10)])
434
+ for i in range(3)
435
+ ]
436
+ result = smoothify(polys, segment_length=1.0)
437
+ assert result.is_valid
438
+
439
+ def test_list_of_linestrings(self):
440
+ lines = [
441
+ LineString([(i * 20, 0), (i * 20 + 5, 5), (i * 20 + 10, 0)])
442
+ for i in range(3)
443
+ ]
444
+ result = smoothify(lines, segment_length=1.0)
445
+ assert result.is_valid
446
+
447
+ def test_mixed_list(self):
448
+ geoms = [
449
+ Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
450
+ LineString([(20, 0), (30, 10)]),
451
+ ]
452
+ result = smoothify(geoms, segment_length=1.0)
453
+ assert result.is_valid
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Smoothing parameters across types
458
+ # ---------------------------------------------------------------------------
459
+ class TestSmoothingParamsAllTypes:
460
+ """Test different smooth_iterations and segment_length across types."""
461
+
462
+ @pytest.fixture(params=[1, 3, 5])
463
+ def iterations(self, request):
464
+ return request.param
465
+
466
+ @pytest.fixture(params=[0.5, 1.0, 5.0])
467
+ def seg_len(self, request):
468
+ return request.param
469
+
470
+ def test_polygon_iterations(self, iterations, seg_len):
471
+ poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
472
+ result = smoothify(poly, segment_length=seg_len, smooth_iterations=iterations)
473
+ assert isinstance(result, Polygon)
474
+ assert result.is_valid
475
+
476
+ def test_linestring_iterations(self, iterations, seg_len):
477
+ line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
478
+ result = smoothify(line, segment_length=seg_len, smooth_iterations=iterations)
479
+ assert isinstance(result, LineString)
480
+
481
+ def test_multipolygon_iterations(self, iterations, seg_len):
482
+ mp = MultiPolygon([
483
+ Polygon([(0, 0), (8, 0), (8, 8), (0, 8)]),
484
+ Polygon([(20, 0), (28, 0), (28, 8), (20, 8)]),
485
+ ])
486
+ result = smoothify(mp, segment_length=seg_len,
487
+ smooth_iterations=iterations, merge_multipolygons=False)
488
+ assert result.is_valid
489
+
490
+ def test_multilinestring_iterations(self, iterations, seg_len):
491
+ ml = MultiLineString([
492
+ [(0, 0), (5, 5), (10, 0)],
493
+ [(20, 0), (25, 5), (30, 0)],
494
+ ])
495
+ result = smoothify(ml, segment_length=seg_len, smooth_iterations=iterations)
496
+ assert result.is_valid
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Area preservation across polygon types
501
+ # ---------------------------------------------------------------------------
502
+ class TestAreaPreservation:
503
+ """Area preservation for different polygon shapes."""
504
+
505
+ @pytest.mark.parametrize("preserve", [True, False])
506
+ def test_square_area(self, preserve):
507
+ poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
508
+ result = smoothify(poly, segment_length=5.0, preserve_area=preserve)
509
+ assert isinstance(result, Polygon)
510
+ if preserve:
511
+ assert abs(result.area - poly.area) / poly.area < 0.05
512
+
513
+ @pytest.mark.parametrize("preserve", [True, False])
514
+ def test_star_area(self, preserve):
515
+ coords = []
516
+ for i in range(10):
517
+ angle = math.radians(36 * i)
518
+ r = 20 if i % 2 == 0 else 8
519
+ coords.append((r * math.cos(angle), r * math.sin(angle)))
520
+ poly = Polygon(coords)
521
+ result = smoothify(poly, segment_length=1.0, preserve_area=preserve)
522
+ assert isinstance(result, Polygon)
523
+ assert result.is_valid
524
+
525
+ @pytest.mark.parametrize("preserve", [True, False])
526
+ def test_polygon_with_hole_area(self, preserve):
527
+ exterior = [(0, 0), (50, 0), (50, 50), (0, 50)]
528
+ hole = [(15, 15), (35, 15), (35, 35), (15, 35)]
529
+ poly = Polygon(exterior, [hole])
530
+ result = smoothify(poly, segment_length=1.0, preserve_area=preserve)
531
+ assert result.is_valid
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # Degenerate / tricky geometries
536
+ # ---------------------------------------------------------------------------
537
+ class TestDegenerateGeometries:
538
+ """Degenerate, invalid, or tricky geometries that stress edge cases."""
539
+
540
+ def test_bowtie_polygon(self):
541
+ """Self-intersecting (bowtie) polygon — must be made valid first."""
542
+ geom = wkt.loads("POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))")
543
+ assert not geom.is_valid
544
+ geom = make_valid(geom)
545
+ result = smoothify(geom, segment_length=0.5)
546
+ assert result.is_valid
547
+
548
+ def test_polygon_hole_touching_exterior(self):
549
+ """Hole that shares a vertex with the exterior ring."""
550
+ geom = wkt.loads(
551
+ "POLYGON((0 0,10 0,10 10,0 10,0 0),(5 0,8 3,5 6,2 3,5 0))"
552
+ )
553
+ assert geom.is_valid
554
+ result = smoothify(geom, segment_length=1.0)
555
+ assert result.is_valid
556
+
557
+ def test_spike_polygon(self):
558
+ """Polygon with a degenerate zero-width spike."""
559
+ geom = wkt.loads("POLYGON((0 0,10 0,10 10,5 10,5 0,0 0))")
560
+ # Shapely treats this as invalid; make_valid turns it into a collection
561
+ geom = make_valid(geom)
562
+ result = smoothify(geom, segment_length=1.0)
563
+ assert result.is_valid
564
+
565
+ def test_multipolygon_touching_at_edge(self):
566
+ """Two polygons sharing an entire edge — invalid MultiPolygon."""
567
+ geom = wkt.loads(
568
+ "MULTIPOLYGON(((0 0,5 0,5 5,0 5,0 0)),((5 0,10 0,10 5,5 5,5 0)))"
569
+ )
570
+ geom = make_valid(geom)
571
+ result = smoothify(geom, segment_length=1.0)
572
+ assert result.is_valid
573
+
574
+ def test_empty_polygon(self):
575
+ geom = wkt.loads("POLYGON EMPTY")
576
+ result = smoothify(geom, segment_length=1.0)
577
+ assert result.is_empty
578
+
579
+ def test_empty_linestring(self):
580
+ geom = LineString()
581
+ result = smoothify(geom, segment_length=1.0)
582
+ assert result.is_empty
583
+
584
+ def test_empty_multipolygon(self):
585
+ geom = MultiPolygon()
586
+ result = smoothify(geom, segment_length=1.0)
587
+ assert result.is_valid
588
+
589
+ def test_empty_multilinestring(self):
590
+ geom = MultiLineString()
591
+ result = smoothify(geom, segment_length=1.0)
592
+ assert result.is_valid
593
+
594
+ def test_duplicate_points_linestring(self):
595
+ """LineString with consecutive duplicate points."""
596
+ geom = wkt.loads("LINESTRING(0 0,0 0,1 1,2 2,2 2)")
597
+ result = smoothify(geom, segment_length=0.5)
598
+ assert isinstance(result, LineString)
599
+ assert result.is_valid
600
+
601
+ def test_near_zero_area_polygon(self):
602
+ """Extremely thin polygon that is nearly degenerate."""
603
+ poly = Polygon([(0, 0), (100, 0), (100, 0.001), (0, 0.001)])
604
+ result = smoothify(poly, segment_length=0.01)
605
+ assert result.is_valid
606
+
607
+ def test_polygon_with_collinear_points(self):
608
+ """Polygon where several consecutive vertices are collinear."""
609
+ poly = Polygon([
610
+ (0, 0), (2, 0), (5, 0), (8, 0), (10, 0),
611
+ (10, 10), (0, 10),
612
+ ])
613
+ result = smoothify(poly, segment_length=1.0)
614
+ assert isinstance(result, Polygon)
615
+ assert result.is_valid
616
+
617
+ def test_linestring_with_collinear_points(self):
618
+ """LineString where all points are collinear."""
619
+ line = LineString([(0, 0), (1, 0), (2, 0), (5, 0), (10, 0)])
620
+ result = smoothify(line, segment_length=1.0)
621
+ assert isinstance(result, LineString)
622
+
623
+ def test_almost_closed_linestring(self):
624
+ """LineString whose start and end are nearly identical."""
625
+ line = LineString([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.001)])
626
+ result = smoothify(line, segment_length=1.0)
627
+ assert isinstance(result, LineString)
628
+
629
+ def test_polygon_hole_nearly_fills_exterior(self):
630
+ """Hole that is almost as large as the exterior."""
631
+ exterior = [(0, 0), (10, 0), (10, 10), (0, 10)]
632
+ hole = [(0.5, 0.5), (9.5, 0.5), (9.5, 9.5), (0.5, 9.5)]
633
+ poly = Polygon(exterior, [hole])
634
+ result = smoothify(poly, segment_length=0.5)
635
+ assert result.is_valid
636
+
637
+ def test_reversed_ring_orientation(self):
638
+ """Polygon with clockwise exterior (non-standard orientation)."""
639
+ # Shapely normalises this, but real files may have it
640
+ poly = Polygon([(0, 0), (0, 10), (10, 10), (10, 0)]) # CW
641
+ result = smoothify(poly, segment_length=1.0)
642
+ assert isinstance(result, Polygon)
643
+ assert result.is_valid
644
+
645
+ def test_zero_area_ring(self):
646
+ """Polygon that degenerates to a line (zero area)."""
647
+ poly = Polygon([(0, 0), (10, 0), (5, 0)])
648
+ # Shapely makes this empty/invalid
649
+ if poly.is_empty or not poly.is_valid:
650
+ poly = make_valid(poly)
651
+ result = smoothify(poly, segment_length=1.0)
652
+ assert result.is_valid
653
+
654
+ def test_nearly_coincident_vertices(self):
655
+ """Polygon with vertices separated by < 1e-10."""
656
+ poly = Polygon([
657
+ (0, 0), (10, 0), (10, 1e-11), (10, 10), (0, 10),
658
+ ])
659
+ result = smoothify(poly, segment_length=1.0)
660
+ assert result.is_valid
661
+
662
+ def test_overlapping_multipolygon(self):
663
+ """MultiPolygon with overlapping members."""
664
+ p1 = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
665
+ p2 = Polygon([(5, 5), (15, 5), (15, 15), (5, 15)])
666
+ geom = make_valid(MultiPolygon([p1, p2]))
667
+ result = smoothify(geom, segment_length=1.0)
668
+ assert result.is_valid
669
+
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Z coordinates
673
+ # ---------------------------------------------------------------------------
674
+ class TestZCoordinates:
675
+ """Geometries with Z dimension (common in real shapefiles)."""
676
+
677
+ def test_polygon_z(self):
678
+ poly = Polygon([(0, 0, 5), (10, 0, 5), (10, 10, 5), (0, 10, 5)])
679
+ result = smoothify(poly, segment_length=1.0)
680
+ assert isinstance(result, Polygon)
681
+ assert result.is_valid
682
+
683
+ def test_linestring_z(self):
684
+ line = LineString([(0, 0, 0), (5, 5, 10), (10, 0, 20)])
685
+ result = smoothify(line, segment_length=1.0)
686
+ assert isinstance(result, LineString)
687
+
688
+ def test_multipolygon_z(self):
689
+ p1 = Polygon([(0, 0, 1), (5, 0, 1), (5, 5, 1), (0, 5, 1)])
690
+ p2 = Polygon([(20, 20, 2), (25, 20, 2), (25, 25, 2), (20, 25, 2)])
691
+ mp = MultiPolygon([p1, p2])
692
+ result = smoothify(mp, segment_length=1.0, merge_multipolygons=False)
693
+ assert result.is_valid
694
+
695
+ def test_multilinestring_z(self):
696
+ ml = MultiLineString([
697
+ [(0, 0, 0), (5, 5, 10)],
698
+ [(10, 0, 0), (15, 5, 10)],
699
+ ])
700
+ result = smoothify(ml, segment_length=1.0)
701
+ assert result.is_valid
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Numeric edge cases
706
+ # ---------------------------------------------------------------------------
707
+ class TestNumericEdgeCases:
708
+ """Extreme coordinate values and numeric stability."""
709
+
710
+ def test_very_large_coordinates(self):
711
+ """Coordinates typical of projected CRS (e.g. UTM easting/northing)."""
712
+ poly = Polygon([
713
+ (500000, 6000000), (500100, 6000000),
714
+ (500100, 6000100), (500000, 6000100),
715
+ ])
716
+ result = smoothify(poly, segment_length=10.0)
717
+ assert isinstance(result, Polygon)
718
+ assert result.is_valid
719
+
720
+ def test_very_small_coordinates(self):
721
+ """Tiny fractional coordinates."""
722
+ poly = Polygon([
723
+ (0, 0), (1e-6, 0), (1e-6, 1e-6), (0, 1e-6),
724
+ ])
725
+ result = smoothify(poly, segment_length=1e-7)
726
+ assert isinstance(result, Polygon)
727
+ assert result.is_valid
728
+
729
+ def test_large_coordinate_linestring(self):
730
+ line = LineString([
731
+ (1e7, 1e7), (1e7 + 50, 1e7 + 50), (1e7 + 100, 1e7),
732
+ ])
733
+ result = smoothify(line, segment_length=5.0)
734
+ assert isinstance(result, LineString)
735
+
736
+ def test_negative_coordinates(self):
737
+ poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
738
+ result = smoothify(poly, segment_length=1.0)
739
+ assert isinstance(result, Polygon)
740
+ assert result.is_valid
741
+
742
+ def test_mixed_sign_coordinates(self):
743
+ """Geometry spanning the origin."""
744
+ line = LineString([(-10, -5), (0, 5), (10, -5)])
745
+ result = smoothify(line, segment_length=1.0)
746
+ assert isinstance(result, LineString)
747
+
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Collapse / degenerate output cases
751
+ # ---------------------------------------------------------------------------
752
+ class TestCollapseOutputs:
753
+ """Inputs that may collapse or degenerate after smoothing."""
754
+
755
+ def test_tiny_polygon_survives(self):
756
+ """Very small polygon should not vanish."""
757
+ poly = Polygon([(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)])
758
+ result = smoothify(poly, segment_length=0.01)
759
+ assert result.is_valid
760
+ # Should still have some area
761
+ if isinstance(result, Polygon):
762
+ assert result.area > 0
763
+
764
+ def test_tiny_triangle(self):
765
+ poly = Polygon([(0, 0), (0.01, 0), (0.005, 0.01)])
766
+ result = smoothify(poly, segment_length=0.001)
767
+ assert result.is_valid
768
+
769
+ def test_very_short_linestring(self):
770
+ """Two-point line shorter than segment_length."""
771
+ line = LineString([(0, 0), (0.01, 0.01)])
772
+ result = smoothify(line, segment_length=1.0)
773
+ assert isinstance(result, LineString)
774
+
775
+ def test_large_segment_length_polygon(self):
776
+ """segment_length much larger than the geometry — aggressive simplification."""
777
+ poly = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
778
+ result = smoothify(poly, segment_length=100.0)
779
+ assert result.is_valid
780
+
781
+ def test_large_segment_length_linestring(self):
782
+ line = LineString([(0, 0), (1, 1), (2, 0), (3, 1), (4, 0)])
783
+ result = smoothify(line, segment_length=100.0)
784
+ assert isinstance(result, LineString)
785
+
786
+
787
+ # ---------------------------------------------------------------------------
788
+ # Invariant / property tests
789
+ # ---------------------------------------------------------------------------
790
+ class TestInvariants:
791
+ """Property-based invariants that should hold for all smoothing operations."""
792
+
793
+ def test_idempotence_polygon(self):
794
+ """Smoothing twice should not drift much after the first pass."""
795
+ poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
796
+ once = smoothify(poly, segment_length=2.0)
797
+ twice = smoothify(once, segment_length=2.0)
798
+ assert isinstance(once, Polygon)
799
+ assert isinstance(twice, Polygon)
800
+ # Second pass should change area by < 5%
801
+ assert abs(twice.area - once.area) / once.area < 0.05
802
+
803
+ def test_idempotence_linestring(self):
804
+ line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
805
+ once = smoothify(line, segment_length=1.0)
806
+ twice = smoothify(once, segment_length=1.0)
807
+ assert isinstance(once, LineString)
808
+ assert isinstance(twice, LineString)
809
+ # Length should not drift wildly
810
+ assert abs(twice.length - once.length) / once.length < 0.15
811
+
812
+ def test_bounded_area_distortion_polygon(self):
813
+ """Area change should be bounded (within 10% without preserve_area)."""
814
+ poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
815
+ result = smoothify(poly, segment_length=5.0, preserve_area=False)
816
+ assert isinstance(result, Polygon)
817
+ ratio = result.area / poly.area
818
+ assert 0.8 < ratio < 1.2, f"Area ratio {ratio:.3f} outside bounds"
819
+
820
+ def test_area_preservation_tight(self):
821
+ """With preserve_area=True, area should be within tolerance."""
822
+ poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
823
+ result = smoothify(poly, segment_length=5.0, preserve_area=True,
824
+ area_tolerance=0.01)
825
+ assert isinstance(result, Polygon)
826
+ pct_error = abs(result.area - poly.area) / poly.area * 100
827
+ assert pct_error < 1.0, f"Area error {pct_error:.4f}% exceeds 1%"
828
+
829
+ def test_hausdorff_distance_bounded_polygon(self):
830
+ """Smoothed polygon should stay close to original."""
831
+ poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
832
+ result = smoothify(poly, segment_length=2.0)
833
+ assert isinstance(result, Polygon)
834
+ dist = poly.hausdorff_distance(result)
835
+ # Should not drift more than a few segment_lengths
836
+ assert dist < 10.0, f"Hausdorff distance {dist:.2f} too large"
837
+
838
+ def test_hausdorff_distance_bounded_linestring(self):
839
+ """Smoothed line should stay close to original."""
840
+ line = LineString([(0, 0), (5, 5), (10, 0), (15, 5), (20, 0)])
841
+ result = smoothify(line, segment_length=1.0)
842
+ assert isinstance(result, LineString)
843
+ dist = line.hausdorff_distance(result)
844
+ assert dist < 5.0, f"Hausdorff distance {dist:.2f} too large"
845
+
846
+ def test_envelope_containment_polygon(self):
847
+ """Smoothed polygon envelope should be similar to original."""
848
+ poly = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])
849
+ result = smoothify(poly, segment_length=5.0)
850
+ assert isinstance(result, Polygon)
851
+ # Result bounding box should not be wildly different
852
+ orig_bounds = poly.bounds
853
+ res_bounds = result.bounds
854
+ for i in range(4):
855
+ assert abs(orig_bounds[i] - res_bounds[i]) < 10.0
856
+
857
+ def test_vertex_count_increases(self):
858
+ """Smoothing should generally add vertices."""
859
+ poly = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
860
+ result = smoothify(poly, segment_length=2.0)
861
+ assert isinstance(result, Polygon)
862
+ assert len(result.exterior.coords) > len(poly.exterior.coords)
863
+
864
+ def test_output_always_valid(self):
865
+ """Diverse set of inputs should always produce valid output."""
866
+ inputs = [
867
+ Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
868
+ LineString([(0, 0), (5, 5), (10, 0)]),
869
+ MultiPolygon([
870
+ Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]),
871
+ Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]),
872
+ ]),
873
+ MultiLineString([[(0, 0), (5, 5)], [(10, 0), (15, 5)]]),
874
+ GeometryCollection([
875
+ Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]),
876
+ LineString([(20, 0), (30, 10)]),
877
+ ]),
878
+ ]
879
+ for geom in inputs:
880
+ result = smoothify(geom, segment_length=1.0)
881
+ assert result.is_valid, f"Invalid output for {geom.geom_type}"
882
+
883
+ def test_linestring_endpoints_preserved(self):
884
+ """Endpoints must be preserved across various LineStrings."""
885
+ lines = [
886
+ LineString([(0, 0), (10, 10)]),
887
+ LineString([(0, 0), (5, 5), (10, 0)]),
888
+ LineString([(i, math.sin(i)) for i in range(20)]),
889
+ ]
890
+ for line in lines:
891
+ result = smoothify(line, segment_length=1.0)
892
+ assert isinstance(result, LineString)
893
+ assert result.coords[0] == pytest.approx(line.coords[0], abs=1e-6)
894
+ assert result.coords[-1] == pytest.approx(line.coords[-1], abs=1e-6)
@@ -143,6 +143,26 @@ class TestSmoothifyGeometry:
143
143
  assert smoothed.coords[0] == line.coords[0]
144
144
  assert smoothed.coords[-1] == line.coords[-1]
145
145
 
146
+ def test_smoothify_self_intersecting_linestring(self):
147
+ """Test that a self-intersecting LineString is smoothed without error.
148
+
149
+ Lines that cross themselves are geometrically valid. The smoothing
150
+ pipeline must not split them into a MultiLineString via make_valid
151
+ or unary_union, which node lines at self-intersection points.
152
+ """
153
+ # S-curve that crosses itself — triggers unary_union splitting
154
+ line = LineString([
155
+ (0, 0), (2, 0), (3, 0), (4, 1), (3, 2), (2, 2), (1, 1),
156
+ (2, 0.5), (3, 0.5), (4, 0), (5, 0), (7, 0),
157
+ ])
158
+ assert not line.is_simple # confirm it self-intersects
159
+
160
+ smoothed = _smoothify_geometry(
161
+ line, segment_length=0.5, smooth_iterations=3, preserve_area=False
162
+ )
163
+
164
+ assert isinstance(smoothed, LineString)
165
+
146
166
  def test_preserve_area_option(self):
147
167
  """Test that preserve_area option works."""
148
168
  square = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"
File without changes
File without changes
File without changes
File without changes