diff --git a/map_machine/boundary_box.py b/map_machine/boundary_box.py index 047485b..ca490a7 100644 --- a/map_machine/boundary_box.py +++ b/map_machine/boundary_box.py @@ -76,6 +76,45 @@ class BoundaryBox: return cls(left, bottom, right, top) + @classmethod + def from_coordinates( + cls, + coordinates: np.ndarray, + zoom_level: float, + width: float, + height: float, + ) -> "BoundaryBox": + """ + Compute boundary box from central coordinates, zoom level and resulting + image size. + + :param coordinates: boundary box central coordinates + :param zoom_level: resulting image zoom level + :param width: resulting image width + :param height: resulting image height + """ + lat_rad: np.ndarray = np.radians(coordinates[0]) + n: float = 2.0 ** (zoom_level + 8.0) + + x: int = int((coordinates[1] + 180.0) / 360.0 * n) + left: float = (x - width / 2) / n * 360.0 - 180.0 + right: float = (x + width / 2) / n * 360.0 - 180.0 + + y: int = (1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * n + bottom_radians = np.arctan( + np.sinh((1.0 - (y + height / 2) * 2.0 / n) * np.pi) + ) + top_radians = np.arctan( + np.sinh((1.0 - (y - height / 2) * 2.0 / n) * np.pi) + ) + + return cls( + left, + float(np.degrees(bottom_radians)), + right, + float(np.degrees(top_radians)), + ) + def min_(self) -> np.ndarray: """Get minimum coordinates.""" return np.array((self.bottom, self.left)) @@ -113,6 +152,16 @@ class BoundaryBox: ,,,. Coordinates are rounded to three digits after comma. """ - return ( - f"{self.left:.3f},{self.bottom:.3f},{self.right:.3f},{self.top:.3f}" - ) + left: float = np.floor(self.left * 1000) / 1000 + bottom: float = np.floor(self.bottom * 1000) / 1000 + right: float = np.ceil(self.right * 1000) / 1000 + top: float = np.ceil(self.top * 1000) / 1000 + + return f"{left:.3f},{bottom:.3f},{right:.3f},{top:.3f}" + + def combine(self, other: "BoundaryBox") -> None: + """Combine with another boundary box.""" + self.left = min(self.left, other.left) + self.right = min(self.right, other.right) + self.bottom = min(self.bottom, other.bottom) + self.top = min(self.top, other.top) diff --git a/map_machine/figure.py b/map_machine/figure.py index 8733e13..52b094e 100644 --- a/map_machine/figure.py +++ b/map_machine/figure.py @@ -406,7 +406,7 @@ class DirectionSector(Tagged): angle = float(self.get_tag("camera:angle")) if "angle" in self.tags: angle = float(self.get_tag("angle")) - direction_radius = 25 + direction_radius = 50 direction_color = scheme.get_color("direction_camera_color") elif self.get_tag("traffic_sign") == "stop": direction = self.get_tag("direction") diff --git a/map_machine/main.py b/map_machine/main.py index a99dcda..85d0c83 100644 --- a/map_machine/main.py +++ b/map_machine/main.py @@ -6,7 +6,7 @@ import logging import sys from pathlib import Path -from map_machine.ui import parse_options +from map_machine.ui import parse_arguments from map_machine.workspace import Workspace __author__ = "Sergey Vartanov" @@ -18,7 +18,7 @@ def main() -> None: logging.basicConfig(format="%(levelname)s %(message)s", level=logging.INFO) workspace: Workspace = Workspace(Path("out")) - arguments: argparse.Namespace = parse_options(sys.argv) + arguments: argparse.Namespace = parse_arguments(sys.argv) if not arguments.command: logging.fatal("No command provided. See --help.") diff --git a/map_machine/map_configuration.py b/map_machine/map_configuration.py index 3ad63ce..060e3e7 100644 --- a/map_machine/map_configuration.py +++ b/map_machine/map_configuration.py @@ -54,6 +54,7 @@ class MapConfiguration: seed: str = "" show_tooltips: bool = False country: str = "world" + ignore_level_matching: bool = False @classmethod def from_options( diff --git a/map_machine/mapper.py b/map_machine/mapper.py index 9648d33..3d88881 100644 --- a/map_machine/mapper.py +++ b/map_machine/mapper.py @@ -20,7 +20,7 @@ from map_machine.flinger import Flinger from map_machine.icon import ShapeExtractor from map_machine.map_configuration import LabelMode, MapConfiguration from map_machine.osm_getter import NetworkError, get_osm -from map_machine.osm_reader import OSMData, OSMNode, OSMReader, OverpassReader +from map_machine.osm_reader import OSMData, OSMNode from map_machine.point import Occupied, Point from map_machine.road import Intersection, RoadPart from map_machine.scheme import Scheme @@ -94,11 +94,11 @@ class Map: for tree in constructor.craters: tree.draw(self.svg, self.flinger) + self.draw_buildings(constructor) + for direction_sector in constructor.direction_sectors: direction_sector.draw(self.svg, self.scheme) - self.draw_buildings(constructor) - # All other points occupied: Optional[Occupied] @@ -201,74 +201,73 @@ class Map: intersection.draw(self.svg, True) -def ui(options: argparse.Namespace) -> None: +def ui(arguments: argparse.Namespace) -> None: """ Map Machine entry point. - :param options: command-line arguments + :param arguments: command-line arguments """ configuration: MapConfiguration = MapConfiguration.from_options( - options, int(options.zoom) + arguments, int(arguments.zoom) ) - if not options.boundary_box and not options.input_file_name: - logging.fatal("Specify either --boundary-box, or --input.") - exit(1) - - if options.boundary_box: - boundary_box: BoundaryBox = BoundaryBox.from_text(options.boundary_box) - - cache_path: Path = Path(options.cache) + cache_path: Path = Path(arguments.cache) cache_path.mkdir(parents=True, exist_ok=True) - input_file_names: list[Path] + boundary_box: Optional[BoundaryBox] = None + input_file_names: list[Path] = [] - if options.input_file_name: - input_file_names = list(map(Path, options.input_file_name)) + if arguments.input_file_names: + input_file_names = list(map(Path, arguments.input_file_names)) else: + if arguments.boundary_box: + boundary_box = BoundaryBox.from_text(arguments.boundary_box) + else: + coordinates: np.ndarray = np.array( + list(map(float, arguments.coordinates.split(","))) + ) + width, height = np.array( + list(map(float, arguments.size.split(","))) + ) + boundary_box = BoundaryBox.from_coordinates( + coordinates, configuration.zoom_level, width, height + ) + try: cache_file_path: Path = ( cache_path / f"{boundary_box.get_format()}.osm" ) get_osm(boundary_box, cache_file_path) + input_file_names = [cache_file_path] except NetworkError as e: logging.fatal(e.message) exit(1) - input_file_names = [cache_file_path] scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) min_: np.ndarray max_: np.ndarray osm_data: OSMData - view_box: BoundaryBox - if input_file_names[0].name.endswith(".json"): - reader: OverpassReader = OverpassReader() - reader.parse_json_file(input_file_names[0]) + osm_data: OSMData = OSMData() - osm_data = reader.osm_data - view_box = boundary_box - else: - osm_reader = OSMReader() + for input_file_name in input_file_names: + if not input_file_name.is_file(): + logging.fatal(f"No such file: {input_file_name}.") + exit(1) - for file_name in input_file_names: - if not file_name.is_file(): - logging.fatal(f"No such file: {file_name}.") - exit(1) - - osm_reader.parse_osm_file(file_name) - - osm_data = osm_reader.osm_data - - if options.boundary_box: - view_box = boundary_box + if input_file_name.name.endswith(".json"): + osm_data.parse_overpass(input_file_name) else: - view_box = osm_data.view_box + osm_data.parse_osm_file(input_file_name) - flinger: Flinger = Flinger(view_box, options.zoom, osm_data.equator_length) + view_box: BoundaryBox = boundary_box if boundary_box else osm_data.view_box + + flinger: Flinger = Flinger( + view_box, arguments.zoom, osm_data.equator_length + ) size: np.ndarray = flinger.size svg: svgwrite.Drawing = svgwrite.Drawing( - options.output_file_name, size=size + arguments.output_file_name, size=size ) icon_extractor: ShapeExtractor = ShapeExtractor( workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH @@ -288,6 +287,6 @@ def ui(options: argparse.Namespace) -> None: ) painter.draw(constructor) - logging.info(f"Writing output SVG to {options.output_file_name}...") - with open(options.output_file_name, "w") as output_file: + logging.info(f"Writing output SVG to {arguments.output_file_name}...") + with open(arguments.output_file_name, "w") as output_file: svg.write(output_file) diff --git a/map_machine/osm_reader.py b/map_machine/osm_reader.py index 959ffde..2c71881 100644 --- a/map_machine/osm_reader.py +++ b/map_machine/osm_reader.py @@ -355,119 +355,97 @@ class OSMData: ) self.relations[relation.id_] = relation + def parse_overpass(self, file_name: Path) -> None: + """ + Parse JSON structure extracted from Overpass API. -class OverpassReader: - """ - Reader for JSON structure extracted from Overpass API. - - See https://wiki.openstreetmap.org/wiki/Overpass_API - """ - - def __init__(self) -> None: - self.osm_data = OSMData() - - def parse_json_file(self, file_name: Path) -> OSMData: - """Parse JSON structure from the file and construct map.""" + See https://wiki.openstreetmap.org/wiki/Overpass_API + """ with file_name.open() as input_file: structure = json.load(input_file) - node_map = {} - way_map = {} + node_map: dict[int, OSMNode] = {} + way_map: dict[int, OSMWay] = {} for element in structure["elements"]: if element["type"] == "node": node = OSMNode.parse_from_structure(element) node_map[node.id_] = node - self.osm_data.add_node(node) + self.add_node(node) + for element in structure["elements"]: if element["type"] == "way": way = OSMWay.parse_from_structure(element, node_map) way_map[way.id_] = way - self.osm_data.add_way(way) + self.add_way(way) + for element in structure["elements"]: if element["type"] == "relation": relation = OSMRelation.parse_from_structure(element) - self.osm_data.add_relation(relation) + self.add_relation(relation) - return self.osm_data - - -class OSMReader: - """ - OpenStreetMap XML file parser. - - See https://wiki.openstreetmap.org/wiki/OSM_XML - """ - - def __init__( - self, - parse_nodes: bool = True, - parse_ways: bool = True, - parse_relations: bool = True, - ) -> None: - """ - :param parse_nodes: whether nodes should be parsed - :param parse_ways: whether ways should be parsed - :param parse_relations: whether relations should be parsed - """ - self.osm_data = OSMData() - self.parse_nodes: bool = parse_nodes - self.parse_ways: bool = parse_ways - self.parse_relations: bool = parse_relations - - def parse_osm_file(self, file_name: Path) -> OSMData: + def parse_osm_file(self, file_name: Path) -> None: """ Parse OSM XML file. + See https://wiki.openstreetmap.org/wiki/OSM_XML + :param file_name: input XML file :return: parsed map """ - return self.parse_osm(ElementTree.parse(file_name).getroot()) + self.parse_osm(ElementTree.parse(file_name).getroot()) - def parse_osm_text(self, text: str) -> OSMData: + def parse_osm_text(self, text: str) -> None: """ Parse OSM XML data from text representation. :param text: XML text representation :return: parsed map """ - return self.parse_osm(ElementTree.fromstring(text)) + self.parse_osm(ElementTree.fromstring(text)) - def parse_osm(self, root: Element) -> OSMData: + def parse_osm( + self, + root: Element, + parse_nodes: bool = True, + parse_ways: bool = True, + parse_relations: bool = True, + ) -> None: """ Parse OSM XML data. :param root: top element of XML data - :return: parsed map + :param parse_nodes: whether nodes should be parsed + :param parse_ways: whether ways should be parsed + :param parse_relations: whether relations should be parsed """ for element in root: if element.tag == "bounds": self.parse_bounds(element) elif element.tag == "object": self.parse_object(element) - elif element.tag == "node" and self.parse_nodes: + elif element.tag == "node" and parse_nodes: node = OSMNode.from_xml_structure(element) - self.osm_data.add_node(node) - elif element.tag == "way" and self.parse_ways: - self.osm_data.add_way( - OSMWay.from_xml_structure(element, self.osm_data.nodes) - ) - elif element.tag == "relation" and self.parse_relations: - self.osm_data.add_relation( - OSMRelation.from_xml_structure(element) - ) - return self.osm_data + self.add_node(node) + elif element.tag == "way" and parse_ways: + self.add_way(OSMWay.from_xml_structure(element, self.nodes)) + elif element.tag == "relation" and parse_relations: + self.add_relation(OSMRelation.from_xml_structure(element)) def parse_bounds(self, element: Element) -> None: """Parse view box from XML element.""" attributes = element.attrib - self.osm_data.view_box = BoundaryBox( + boundary_box: BoundaryBox = BoundaryBox( float(attributes["minlon"]), float(attributes["minlat"]), float(attributes["maxlon"]), float(attributes["maxlat"]), ) + if self.view_box: + self.view_box.combine(boundary_box) + else: + self.view_box = boundary_box def parse_object(self, element: Element) -> None: """Parse astronomical object properties from XML element.""" - self.osm_data.equator_length = float(element.get("equator")) + self.equator_length = float(element.get("equator")) diff --git a/map_machine/scheme.py b/map_machine/scheme.py index 3d3c6e0..2f3e4b5 100644 --- a/map_machine/scheme.py +++ b/map_machine/scheme.py @@ -414,7 +414,10 @@ class Scheme: continue if not matcher.is_matched(tags, configuration): continue - if not matcher.check_zoom_level(configuration.zoom_level): + if ( + not configuration.ignore_level_matching + and not matcher.check_zoom_level(configuration.zoom_level) + ): return None, 0 matcher_tags: set[str] = set(matcher.tags.keys()) priority = len(self.node_matchers) - index diff --git a/map_machine/ui.py b/map_machine/ui.py index d6e16a7..e1d2283 100644 --- a/map_machine/ui.py +++ b/map_machine/ui.py @@ -29,8 +29,8 @@ COMMANDS: dict[str, list[str]] = { } -def parse_options(args: list[str]) -> argparse.Namespace: - """Parse Map Machine command-line options.""" +def parse_arguments(args: list[str]) -> argparse.Namespace: + """Parse Map Machine command-line arguments.""" parser: argparse.ArgumentParser = argparse.ArgumentParser( description="Map Machine. OpenStreetMap renderer with custom icon set" ) @@ -203,7 +203,7 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-i", "--input", - dest="input_file_name", + dest="input_file_names", metavar="", nargs="*", help="input XML file name or names (if not specified, file will be " @@ -238,6 +238,18 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None: help="OSM zoom level", default=18, ) + parser.add_argument( + "-c", + "--coordinates", + metavar=",", + help="coordinates of any location inside the tile", + ) + parser.add_argument( + "-s", + "--size", + metavar=",", + help="resulted image size", + ) def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None: