"""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_tree_row,
)
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)
def test_degenerate(self):
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 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 = """
"""
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("")
handle.close()
try:
with self.assertRaises(RuntimeError):
parse_osm(handle.name)
finally:
os.unlink(handle.name)
class TagsTest(unittest.TestCase):
def test_reads_key_value_children(self):
import xml.etree.ElementTree as ET
element = ET.fromstring(
"")
self.assertEqual(tags(element), {"building": "yes", "height": "9"})
def test_untagged_element(self):
import xml.etree.ElementTree as ET
self.assertEqual(tags(ET.fromstring("")), {})
if __name__ == "__main__":
unittest.main()