"""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 = """ """ 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()