457 lines
18 KiB
Python
457 lines
18 KiB
Python
"""Tests for the bpy-free half of the pipeline.
|
|
|
|
python3 -m unittest discover blender/tests
|
|
|
|
These run without Blender, which is the point of the osmassets split: before
|
|
it, the only way to exercise clip_polygon or sample_tree_row was to render a
|
|
whole area and look at the picture.
|
|
|
|
The expected values are derived from the geometry, not captured from the
|
|
implementation — a test that just records current output would ratify a bug.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
|
|
from osmassets.geom import (
|
|
clip_polygon,
|
|
distance_to_ring,
|
|
feature_in_bounds,
|
|
geometry_rings,
|
|
point_in_polygon,
|
|
polygon_area,
|
|
sample_polygon_interior,
|
|
sample_ring_boundary,
|
|
sample_tree_row,
|
|
signed_polygon_area,
|
|
)
|
|
from osmassets.osm import Projector, parse_height, parse_osm, tags
|
|
|
|
|
|
SQUARE = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
|
|
|
|
|
|
class GeometryRingsTest(unittest.TestCase):
|
|
def test_polygon_keeps_only_the_exterior_ring(self):
|
|
geometry = {"type": "Polygon", "coordinates": [["outer"], ["hole"]]}
|
|
self.assertEqual(geometry_rings(geometry), [["outer"]])
|
|
|
|
def test_multipolygon_takes_each_exterior_ring(self):
|
|
geometry = {"type": "MultiPolygon",
|
|
"coordinates": [[["a"], ["a hole"]], [["b"]]]}
|
|
self.assertEqual(geometry_rings(geometry), [["a"], ["b"]])
|
|
|
|
def test_unsupported_and_empty_geometry(self):
|
|
self.assertEqual(geometry_rings(None), [])
|
|
self.assertEqual(geometry_rings({}), [])
|
|
self.assertEqual(geometry_rings({"type": "LineString",
|
|
"coordinates": [[0, 0], [1, 1]]}), [])
|
|
self.assertEqual(geometry_rings({"type": "MultiPolygon",
|
|
"coordinates": [[], [["b"]]]}), [["b"]])
|
|
|
|
|
|
class ClipPolygonTest(unittest.TestCase):
|
|
def test_polygon_inside_the_box_is_unchanged(self):
|
|
clipped = clip_polygon(SQUARE, -1.0, 11.0, -1.0, 11.0)
|
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y in clipped], SQUARE)
|
|
|
|
def test_half_outside_polygon_is_cut_at_the_boundary(self):
|
|
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
|
|
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
|
|
# A 10x10 square clipped to half its width is a 5x10 rectangle.
|
|
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
|
|
|
|
def test_polygon_fully_outside_collapses(self):
|
|
self.assertEqual(clip_polygon(SQUARE, 20.0, 30.0, 20.0, 30.0), [])
|
|
|
|
def test_degenerate_input(self):
|
|
self.assertEqual(clip_polygon([], 0, 1, 0, 1), [])
|
|
self.assertEqual(clip_polygon([(0.0, 0.0), (1.0, 1.0)], 0, 1, 0, 1), [])
|
|
|
|
def test_axis_aligned_edge_does_not_divide_by_zero(self):
|
|
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
|
|
# guard in the intersection lambdas.
|
|
ring = [(5.0, -5.0), (5.0, 5.0), (-5.0, 5.0), (-5.0, -5.0)]
|
|
clipped = clip_polygon(ring, 0.0, 10.0, 0.0, 10.0)
|
|
self.assertAlmostEqual(polygon_area(clipped), 25.0, places=6)
|
|
|
|
|
|
class PolygonAreaTest(unittest.TestCase):
|
|
def test_square(self):
|
|
self.assertAlmostEqual(polygon_area(SQUARE), 100.0)
|
|
|
|
def test_winding_does_not_change_the_sign(self):
|
|
self.assertAlmostEqual(polygon_area(list(reversed(SQUARE))), 100.0)
|
|
self.assertGreater(signed_polygon_area(SQUARE), 0.0)
|
|
self.assertLess(signed_polygon_area(list(reversed(SQUARE))), 0.0)
|
|
|
|
def test_degenerate(self):
|
|
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 0.0)
|
|
|
|
|
|
class SampleRingBoundaryTest(unittest.TestCase):
|
|
def test_samples_closed_boundary_evenly(self):
|
|
samples = sample_ring_boundary(SQUARE, spacing=10.0)
|
|
self.assertEqual(len(samples), 4)
|
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _, _ in samples],
|
|
[(5.0, 0.0), (10.0, 5.0), (5.0, 10.0), (0.0, 5.0)])
|
|
|
|
def test_repeated_closing_point_is_ignored(self):
|
|
open_samples = sample_ring_boundary(SQUARE, spacing=10.0)
|
|
closed_samples = sample_ring_boundary(SQUARE + [SQUARE[0]], spacing=10.0)
|
|
self.assertEqual(open_samples, closed_samples)
|
|
|
|
def test_inset_moves_samples_inside_for_either_winding(self):
|
|
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
|
|
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
|
|
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
|
|
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
|
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _, _ in ccw],
|
|
[(5.0, 1.0), (9.0, 5.0), (5.0, 9.0), (1.0, 5.0)])
|
|
|
|
def test_max_samples_reduces_density(self):
|
|
samples = sample_ring_boundary(SQUARE, spacing=1.0, max_samples=5)
|
|
self.assertEqual(len(samples), 5)
|
|
|
|
def test_degenerate_input(self):
|
|
self.assertEqual(sample_ring_boundary([(0.0, 0.0)], spacing=1.0), [])
|
|
self.assertEqual(sample_ring_boundary(SQUARE, spacing=0.0), [])
|
|
|
|
|
|
class SamplePolygonInteriorTest(unittest.TestCase):
|
|
def test_samples_are_inside_and_clear_of_edges(self):
|
|
samples = sample_polygon_interior(SQUARE, spacing=3.0, edge_clearance=1.0,
|
|
seed=42)
|
|
self.assertTrue(samples)
|
|
for x, y, _ in samples:
|
|
self.assertTrue(point_in_polygon((x, y), SQUARE))
|
|
self.assertGreaterEqual(distance_to_ring((x, y), SQUARE), 1.0)
|
|
|
|
def test_seed_is_deterministic(self):
|
|
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
|
|
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
|
|
self.assertEqual(first, second)
|
|
|
|
def test_max_samples_reduces_density(self):
|
|
samples = sample_polygon_interior(SQUARE, spacing=1.0, max_samples=4,
|
|
seed=99)
|
|
self.assertEqual(len(samples), 4)
|
|
|
|
def test_degenerate_input(self):
|
|
self.assertEqual(sample_polygon_interior([(0.0, 0.0)], spacing=1.0), [])
|
|
self.assertEqual(sample_polygon_interior(SQUARE, spacing=0.0), [])
|
|
|
|
|
|
class PointInPolygonTest(unittest.TestCase):
|
|
def test_inside_and_outside(self):
|
|
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))
|
|
self.assertFalse(point_in_polygon((15.0, 5.0), SQUARE))
|
|
self.assertFalse(point_in_polygon((5.0, -0.5), SQUARE))
|
|
|
|
def test_concave_notch_is_excluded(self):
|
|
# An L shape: the notch at (8, 8) is outside even though it sits inside
|
|
# the bounding box.
|
|
shape = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0),
|
|
(5.0, 5.0), (5.0, 10.0), (0.0, 10.0)]
|
|
self.assertTrue(point_in_polygon((2.0, 8.0), shape))
|
|
self.assertFalse(point_in_polygon((8.0, 8.0), shape))
|
|
|
|
|
|
class DistanceToRingTest(unittest.TestCase):
|
|
def test_distance_is_to_the_edge_not_the_interior(self):
|
|
# Centre of the square: 5m from every edge, even though it is inside.
|
|
self.assertAlmostEqual(distance_to_ring((5.0, 5.0), SQUARE), 5.0)
|
|
self.assertAlmostEqual(distance_to_ring((1.0, 5.0), SQUARE), 1.0)
|
|
|
|
def test_outside_point(self):
|
|
self.assertAlmostEqual(distance_to_ring((-3.0, 5.0), SQUARE), 3.0)
|
|
|
|
def test_closes_the_ring(self):
|
|
# Nearest edge is the implicit closing segment from (0,10) back to (0,0).
|
|
self.assertAlmostEqual(distance_to_ring((-2.0, 9.0), SQUARE), 2.0)
|
|
|
|
def test_repeated_vertex_does_not_divide_by_zero(self):
|
|
ring = [(0.0, 0.0), (0.0, 0.0), (4.0, 0.0)]
|
|
self.assertAlmostEqual(distance_to_ring((2.0, 3.0), ring), 3.0)
|
|
|
|
|
|
class SampleTreeRowTest(unittest.TestCase):
|
|
def test_even_spacing_along_a_straight_line(self):
|
|
samples = sample_tree_row([(0.0, 0.0), (10.0, 0.0)], spacing=5.0, height=6.0)
|
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _ in samples],
|
|
[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)])
|
|
self.assertTrue(all(h == 6.0 for _, _, h in samples))
|
|
|
|
def test_spacing_carries_across_segment_joins(self):
|
|
# Two 3m segments with 4m spacing: the second sample must land 1m into
|
|
# the second segment, not restart at its origin.
|
|
samples = sample_tree_row([(0.0, 0.0), (3.0, 0.0), (6.0, 0.0)],
|
|
spacing=4.0, height=5.0)
|
|
xs = [round(x, 6) for x, _, _ in samples]
|
|
self.assertEqual(xs, [0.0, 4.0, 6.0])
|
|
|
|
def test_trailing_point_is_skipped_when_it_would_double_plant(self):
|
|
# Endpoint sits 0.2m past the last sample, well under spacing * 0.45.
|
|
samples = sample_tree_row([(0.0, 0.0), (5.2, 0.0)], spacing=5.0, height=5.0)
|
|
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0])
|
|
|
|
def test_zero_length_segment_is_skipped(self):
|
|
samples = sample_tree_row([(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)],
|
|
spacing=5.0, height=5.0)
|
|
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0, 10.0])
|
|
|
|
def test_too_few_points(self):
|
|
self.assertEqual(sample_tree_row([(0.0, 0.0)], spacing=5.0, height=5.0), [])
|
|
|
|
|
|
BOUNDS = {"min_lon": 114.0, "min_lat": 30.0, "max_lon": 114.01, "max_lat": 30.01}
|
|
|
|
|
|
class ProjectorTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.projector = Projector(BOUNDS)
|
|
|
|
def test_centre_of_bounds_is_the_origin(self):
|
|
x, y = self.projector.xy((114.005, 30.005))
|
|
self.assertAlmostEqual(x, 0.0, places=6)
|
|
self.assertAlmostEqual(y, 0.0, places=6)
|
|
|
|
def test_axes_point_east_and_north(self):
|
|
east, _ = self.projector.xy((114.006, 30.005))
|
|
_, north = self.projector.xy((114.005, 30.006))
|
|
self.assertGreater(east, 0.0)
|
|
self.assertGreater(north, 0.0)
|
|
|
|
def test_longitude_metres_shrink_with_latitude(self):
|
|
self.assertAlmostEqual(
|
|
self.projector.m_per_lon,
|
|
111320.0 * math.cos(math.radians(30.005)),
|
|
places=6,
|
|
)
|
|
self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat)
|
|
|
|
def test_inside_honours_the_pad(self):
|
|
self.assertTrue(self.projector.inside((114.005, 30.005)))
|
|
# Default pad is 0.00035 degrees, so just outside the box still counts.
|
|
self.assertTrue(self.projector.inside((114.0102, 30.005)))
|
|
self.assertFalse(self.projector.inside((114.02, 30.005)))
|
|
self.assertFalse(self.projector.inside((114.0102, 30.005), pad=0.0))
|
|
|
|
def test_ring_projects_every_coordinate(self):
|
|
ring = self.projector.ring([(114.0, 30.0), (114.01, 30.01)])
|
|
self.assertEqual(len(ring), 2)
|
|
self.assertLess(ring[0][0], 0.0)
|
|
self.assertGreater(ring[1][0], 0.0)
|
|
|
|
|
|
class FeatureInBoundsTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.projector = Projector(BOUNDS)
|
|
|
|
def test_polygon_with_one_inside_vertex_counts(self):
|
|
feature = {"geometry": {"type": "Polygon", "coordinates": [[
|
|
[120.0, 40.0], [114.005, 30.005], [120.0, 40.0]]]}}
|
|
self.assertTrue(feature_in_bounds(feature, self.projector))
|
|
|
|
def test_feature_fully_outside(self):
|
|
feature = {"geometry": {"type": "Polygon", "coordinates": [[
|
|
[120.0, 40.0], [120.1, 40.1], [120.0, 40.0]]]}}
|
|
self.assertFalse(feature_in_bounds(feature, self.projector))
|
|
|
|
def test_missing_geometry(self):
|
|
self.assertFalse(feature_in_bounds({}, self.projector))
|
|
|
|
|
|
class ParseHeightTest(unittest.TestCase):
|
|
def test_reads_the_tag(self):
|
|
self.assertEqual(parse_height({"height": "24"}, 12.0), 24.0)
|
|
|
|
def test_missing_tag_falls_back(self):
|
|
self.assertEqual(parse_height({}, 12.0), 12.0)
|
|
|
|
def test_unparsable_tag_falls_back(self):
|
|
self.assertEqual(parse_height({"height": "about 20m"}, 12.0), 12.0)
|
|
|
|
def test_clamped_to_half_a_metre(self):
|
|
self.assertEqual(parse_height({"height": "0.1"}, 12.0), 0.5)
|
|
self.assertEqual(parse_height({"height": "-5"}, 12.0), 0.5)
|
|
|
|
|
|
OSM_SAMPLE = """<?xml version='1.0' encoding='UTF-8'?>
|
|
<osm version='0.6'>
|
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
|
<node id='1' lon='114.001' lat='30.001'/>
|
|
<node id='2' lon='114.002' lat='30.002'/>
|
|
<node id='3' lon='114.003' lat='30.003'/>
|
|
<node id='4' lon='114.004' lat='30.004'>
|
|
<tag k='natural' v='tree'/>
|
|
<tag k='height' v='7'/>
|
|
</node>
|
|
<node id='bad' lon='oops' lat='30.0'/>
|
|
<way id='10'>
|
|
<nd ref='1'/><nd ref='2'/><nd ref='3'/>
|
|
<tag k='building' v='yes'/>
|
|
</way>
|
|
<way id='11' action='delete'>
|
|
<nd ref='1'/><nd ref='2'/>
|
|
<tag k='building' v='yes'/>
|
|
</way>
|
|
<way id='12'>
|
|
<nd ref='1'/><nd ref='999'/>
|
|
</way>
|
|
</osm>
|
|
"""
|
|
|
|
|
|
class ParseOsmTest(unittest.TestCase):
|
|
def setUp(self):
|
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
|
encoding="utf-8")
|
|
handle.write(OSM_SAMPLE)
|
|
handle.close()
|
|
self.path = handle.name
|
|
|
|
def tearDown(self):
|
|
os.unlink(self.path)
|
|
|
|
def test_bounds(self):
|
|
bounds, _, _ = parse_osm(self.path)
|
|
self.assertEqual(bounds, {"min_lon": 114.0, "min_lat": 30.0,
|
|
"max_lon": 114.01, "max_lat": 30.01})
|
|
|
|
def test_only_tagged_nodes_become_point_features(self):
|
|
_, _, points = parse_osm(self.path)
|
|
self.assertEqual([p["id"] for p in points], ["4"])
|
|
self.assertEqual(points[0]["tags"], {"natural": "tree", "height": "7"})
|
|
|
|
def test_deleted_ways_are_dropped(self):
|
|
_, ways, _ = parse_osm(self.path)
|
|
self.assertNotIn("11", [w["id"] for w in ways])
|
|
|
|
def test_way_below_two_resolvable_nodes_is_dropped(self):
|
|
# Way 12 references a node that does not exist, leaving one coordinate.
|
|
_, ways, _ = parse_osm(self.path)
|
|
self.assertEqual([w["id"] for w in ways], ["10"])
|
|
self.assertEqual(len(ways[0]["coords"]), 3)
|
|
self.assertEqual(ways[0]["tags"], {"building": "yes"})
|
|
|
|
def test_unparsable_node_is_skipped_not_fatal(self):
|
|
_, _, points = parse_osm(self.path)
|
|
self.assertNotIn("bad", [p["id"] for p in points])
|
|
|
|
def test_missing_bounds_is_an_error(self):
|
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
|
encoding="utf-8")
|
|
handle.write("<osm version='0.6'></osm>")
|
|
handle.close()
|
|
try:
|
|
with self.assertRaises(RuntimeError):
|
|
parse_osm(handle.name)
|
|
finally:
|
|
os.unlink(handle.name)
|
|
|
|
|
|
class ParseOsmMultipolygonTest(unittest.TestCase):
|
|
def parse_text(self, text):
|
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
|
encoding="utf-8")
|
|
handle.write(text)
|
|
handle.close()
|
|
try:
|
|
return parse_osm(handle.name)
|
|
finally:
|
|
os.unlink(handle.name)
|
|
|
|
def test_building_relation_uses_relation_tags_and_members(self):
|
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
|
<osm version='0.6'>
|
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
|
<node id='1' lon='114.001' lat='30.001'/>
|
|
<node id='2' lon='114.004' lat='30.001'/>
|
|
<node id='3' lon='114.004' lat='30.004'/>
|
|
<node id='4' lon='114.001' lat='30.004'/>
|
|
<node id='5' lon='114.002' lat='30.002'/>
|
|
<node id='6' lon='114.003' lat='30.002'/>
|
|
<node id='7' lon='114.003' lat='30.003'/>
|
|
<node id='8' lon='114.002' lat='30.003'/>
|
|
<way id='outer'><nd ref='1'/><nd ref='2'/><nd ref='3'/><nd ref='4'/><nd ref='1'/></way>
|
|
<way id='inner'><nd ref='5'/><nd ref='6'/><nd ref='7'/><nd ref='8'/><nd ref='5'/></way>
|
|
<relation id='65'>
|
|
<member type='way' ref='outer' role='outer'/>
|
|
<member type='way' ref='inner' role='inner'/>
|
|
<tag k='type' v='multipolygon'/>
|
|
<tag k='building' v='yes'/>
|
|
<tag k='height' v='8'/>
|
|
</relation>
|
|
</osm>
|
|
""")
|
|
relation_buildings = [way for way in ways if way["id"] == "65"]
|
|
self.assertEqual(len(relation_buildings), 1)
|
|
building = relation_buildings[0]
|
|
self.assertEqual(building["tags"]["building"], "yes")
|
|
self.assertEqual(building["tags"]["height"], "8")
|
|
self.assertEqual(building["source"], "relation")
|
|
self.assertEqual(len(building["coords"]), 5)
|
|
self.assertEqual(len(building["inner_coords"]), 1)
|
|
self.assertEqual(len(building["inner_coords"][0]), 5)
|
|
|
|
def test_open_outer_members_are_stitched(self):
|
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
|
<osm version='0.6'>
|
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
|
<node id='1' lon='114.001' lat='30.001'/>
|
|
<node id='2' lon='114.004' lat='30.001'/>
|
|
<node id='3' lon='114.004' lat='30.004'/>
|
|
<node id='4' lon='114.001' lat='30.004'/>
|
|
<way id='a'><nd ref='1'/><nd ref='2'/><nd ref='3'/></way>
|
|
<way id='b'><nd ref='3'/><nd ref='4'/><nd ref='1'/></way>
|
|
<relation id='66'>
|
|
<member type='way' ref='a' role='outer'/>
|
|
<member type='way' ref='b' role='outer'/>
|
|
<tag k='type' v='multipolygon'/>
|
|
<tag k='building' v='yes'/>
|
|
</relation>
|
|
</osm>
|
|
""")
|
|
building = [way for way in ways if way["id"] == "66"][0]
|
|
self.assertEqual(len(building["coords"]), 5)
|
|
self.assertEqual(building["coords"][0], building["coords"][-1])
|
|
|
|
def test_incomplete_relation_member_is_skipped_not_fatal(self):
|
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
|
<osm version='0.6'>
|
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
|
<node id='1' lon='114.001' lat='30.001'/>
|
|
<node id='2' lon='114.004' lat='30.001'/>
|
|
<way id='broken'><nd ref='1'/><nd ref='2'/></way>
|
|
<relation id='67'>
|
|
<member type='way' ref='broken' role='outer'/>
|
|
<member type='way' ref='missing' role='outer'/>
|
|
<tag k='type' v='multipolygon'/>
|
|
<tag k='building' v='yes'/>
|
|
</relation>
|
|
</osm>
|
|
""")
|
|
self.assertEqual([way["id"] for way in ways], ["broken"])
|
|
|
|
|
|
class TagsTest(unittest.TestCase):
|
|
def test_reads_key_value_children(self):
|
|
import xml.etree.ElementTree as ET
|
|
element = ET.fromstring(
|
|
"<way><tag k='building' v='yes'/><tag k='height' v='9'/></way>")
|
|
self.assertEqual(tags(element), {"building": "yes", "height": "9"})
|
|
|
|
def test_untagged_element(self):
|
|
import xml.etree.ElementTree as ET
|
|
self.assertEqual(tags(ET.fromstring("<way/>")), {})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|