"""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 json 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.catalog import ROAD_LAYERS from osmassets.native_road_manifest import CONTRACT, load as load_native_road_manifest 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 RoadLayerCatalogTest(unittest.TestCase): def test_intersection_and_road_asphalt_share_the_same_colour(self): layers = {layer["id"]: layer for layer in ROAD_LAYERS} self.assertEqual(layers["road_surface"]["color"], layers["intersection_surface"]["color"]) # They remain distinct meshes so the intersection stays above the road. self.assertNotEqual(layers["road_surface"]["z"], layers["intersection_surface"]["z"]) def test_cesium_export_keeps_signal_assets_optional(self): exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "export_cesium.py") with open(exporter, encoding="utf-8") as handle: source = handle.read() self.assertIn('if not args.get(key):\n continue', source) class NativeRoadManifestTest(unittest.TestCase): def setUp(self): self.root = tempfile.TemporaryDirectory() self.layers_dir = os.path.join(self.root.name, "layers") os.mkdir(self.layers_dir) def tearDown(self): self.root.cleanup() def write_manifest(self, layers): with open(os.path.join(self.root.name, "manifest.json"), "w", encoding="utf-8") as handle: json.dump({"contract": CONTRACT, "areaId": "test", "layers": layers}, handle) for layer in layers: with open(os.path.join(self.layers_dir, layer["source"] + ".geojson"), "w", encoding="utf-8") as handle: json.dump({"type": "FeatureCollection", "features": []}, handle) def test_accepts_semantic_and_split_layers(self): layers = [ {"source": "road_surface", "role": "surface", "materialLayer": "road_surface"}, {"source": "center_lines", "role": "marking", "materialLayer": "center_lines", "splitBy": {"prop": "color", "cases": [ {"match": "white", "material": "native_center_line_white"}, {"default": True, "material": "center_lines"}, ]}}, {"source": "connectors", "role": "semantic"}, ] self.write_manifest(layers) self.assertEqual(load_native_road_manifest(self.root.name)["layers"], layers) def test_rejects_undeclared_or_missing_geojson(self): layers = [{"source": "road_surface", "role": "surface", "materialLayer": "road_surface"}] self.write_manifest(layers) with open(os.path.join(self.layers_dir, "extra.geojson"), "w", encoding="utf-8") as handle: handle.write("{}") with self.assertRaisesRegex(RuntimeError, "mismatch"): load_native_road_manifest(self.root.name) os.unlink(os.path.join(self.layers_dir, "extra.geojson")) os.unlink(os.path.join(self.layers_dir, "road_surface.geojson")) with self.assertRaisesRegex(RuntimeError, "mismatch"): load_native_road_manifest(self.root.name) def test_rejects_invalid_semantic_and_split_definitions(self): self.write_manifest([{"source": "connectors", "role": "semantic", "materialLayer": "road_surface"}]) with self.assertRaisesRegex(RuntimeError, "semantic"): load_native_road_manifest(self.root.name) self.write_manifest([{"source": "center_lines", "role": "marking", "materialLayer": "center_lines", "splitBy": { "prop": "color", "cases": [ {"match": "white", "material": "native_center_line_white"}, {"default": True, "material": "center_lines"}, {"default": True, "material": "center_lines"}, ]}}]) with self.assertRaisesRegex(RuntimeError, "exactly one default"): load_native_road_manifest(self.root.name) 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_wgs84_local_scale_matches_ellipsoid(self): latitude = math.radians(30.005) denominator = math.sqrt(1.0 - Projector.WGS84_E2 * math.sin(latitude) ** 2) expected_lon = (Projector.WGS84_A / denominator * math.cos(latitude) * math.pi / 180.0) expected_lat = (Projector.WGS84_A * (1.0 - Projector.WGS84_E2) / denominator ** 3 * math.pi / 180.0) self.assertAlmostEqual(self.projector.m_per_lon, expected_lon, places=6) self.assertAlmostEqual(self.projector.m_per_lat, expected_lat, places=6) self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat) def test_projection_matches_local_wgs84_scale(self): east, _ = self.projector.xy((114.006, 30.005)) _, north = self.projector.xy((114.005, 30.006)) self.assertAlmostEqual(east, self.projector.m_per_lon * 0.001, places=4) self.assertAlmostEqual(north, self.projector.m_per_lat * 0.001, places=4) 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 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(""" """) 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(""" """) 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(""" """) 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( "") 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()