diff --git a/map_machine/constructor.py b/map_machine/constructor.py index bbee6f5..951841e 100644 --- a/map_machine/constructor.py +++ b/map_machine/constructor.py @@ -15,10 +15,10 @@ from map_machine.figure import ( Building, Crater, DirectionSector, - Road, StyledFigure, Tree, ) +from map_machine.road import Road, Roads from map_machine.flinger import Flinger from map_machine.icon import ( DEFAULT_SMALL_SHAPE_ID, @@ -185,7 +185,7 @@ class Constructor: self.points: list[Point] = [] self.figures: list[StyledFigure] = [] self.buildings: list[Building] = [] - self.roads: list[Road] = [] + self.roads: Roads = Roads() self.trees: list[Tree] = [] self.craters: list[Crater] = [] self.direction_sectors: list[DirectionSector] = [] @@ -260,7 +260,9 @@ class Constructor: road_matcher: RoadMatcher = self.scheme.get_road(line.tags) if road_matcher: - self.roads.append(Road(line.tags, inners, outers, road_matcher)) + self.roads.append( + Road(line.tags, outers[0], road_matcher, self.flinger) + ) return processed: set[str] = set() diff --git a/map_machine/figure.py b/map_machine/figure.py index 52b094e..ef45212 100644 --- a/map_machine/figure.py +++ b/map_machine/figure.py @@ -5,7 +5,6 @@ from typing import Any, Iterator, Optional import numpy as np from colour import Color -from shapely.geometry import LineString from svgwrite import Drawing from svgwrite.container import Group from svgwrite.path import Path @@ -14,12 +13,13 @@ from map_machine.direction import DirectionSet, Sector from map_machine.drawing import PathCommands from map_machine.flinger import Flinger from map_machine.osm_reader import OSMNode, Tagged -from map_machine.road import Lane -from map_machine.scheme import LineStyle, RoadMatcher, Scheme +from map_machine.scheme import LineStyle, Scheme __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" +from map_machine.vector import Polyline + BUILDING_HEIGHT_SCALE: float = 2.5 BUILDING_MINIMAL_HEIGHT: float = 8.0 @@ -61,20 +61,6 @@ class Figure(Tagged): return path - def get_outer_path( - self, flinger: Flinger, parallel_offset: float = 0 - ) -> str: - """Get path of the first outer node list.""" - points: list[tuple[float, float]] = [ - tuple(flinger.fling(x.coordinates)) for x in self.outers[0] - ] - offset = LineString(points).parallel_offset(parallel_offset) - - path: str = "" - for index, point in enumerate(offset.coords): - path += ("L" if index else "M") + f" {point[0]},{point[1]} " - return path[:-1] - class Building(Figure): """ @@ -221,101 +207,6 @@ class StyledFigure(Figure): self.line_style: LineStyle = line_style -class Road(Figure): - """ - Road or track on the map. - """ - - def __init__( - self, - tags: dict[str, str], - inners: list[list[OSMNode]], - outers: list[list[OSMNode]], - matcher: RoadMatcher, - ) -> None: - super().__init__(tags, inners, outers) - self.matcher: RoadMatcher = matcher - - self.width: Optional[float] = None - self.lanes: list[Lane] = [] - - if "lanes" in tags: - try: - self.width = int(tags["lanes"]) * 3.7 - self.lanes = [Lane()] * int(tags["lanes"]) - except ValueError: - pass - - number: int - if "lanes:forward" in tags: - number = int(tags["lanes:forward"]) - [x.set_forward(True) for x in self.lanes[-number:]] - if "lanes:backward" in tags: - number = int(tags["lanes:backward"]) - [x.set_forward(False) for x in self.lanes[:number]] - - if "width" in tags: - try: - self.width = float(tags["width"]) - except ValueError: - pass - - self.layer: float = 0 - if "layer" in tags: - self.layer = float(tags["layer"]) - - def draw( - self, - svg: Drawing, - flinger: Flinger, - color: Color, - extra_width: float = 0, - ) -> None: - """Draw road as simple SVG path.""" - flinger.get_scale() - width: float - if self.width is not None: - width = self.width - else: - width = self.matcher.default_width - cap: str = "round" - if extra_width: - cap = "butt" - if self.tags.get("bridge") == "yes": - color = Color("#666666") - scale: float = flinger.get_scale(self.outers[0][0].coordinates) - path_commands: str = self.get_path(flinger) - path: Path = Path(d=path_commands) - style: dict[str, Any] = { - "fill": "none", - "stroke": color.hex, - "stroke-linecap": cap, - "stroke-linejoin": "round", - "stroke-width": scale * width + extra_width, - } - path.update(style) - svg.add(path) - - def draw_lanes(self, svg: Drawing, flinger: Flinger, color: Color) -> None: - scale: float = flinger.get_scale(self.outers[0][0].coordinates) - if len(self.lanes) < 2: - return - for index in range(1, len(self.lanes)): - shift = scale * ( - -self.width / 2 + index * self.width / len(self.lanes) - ) - path: Path = Path(d=self.get_outer_path(flinger, shift)) - style: dict[str, Any] = { - "fill": "none", - "stroke": color.hex, - "stroke-linejoin": "round", - "stroke-width": 1, - "opacity": 0.5, - } - path.update(style) - svg.add(path) - - class Crater(Tagged): """ Volcano or impact crater on the map. @@ -510,14 +401,6 @@ def make_counter_clockwise(polygon: list[OSMNode]) -> list[OSMNode]: def get_path(nodes: list[OSMNode], shift: np.ndarray, flinger: Flinger) -> str: """Construct SVG path commands from nodes.""" - path: str = "" - prev_node: Optional[OSMNode] = None - for node in nodes: - flung: np.ndarray = flinger.fling(node.coordinates) + shift - path += ("L" if prev_node else "M") + f" {flung[0]},{flung[1]} " - prev_node = node - if nodes[0] == nodes[-1]: - path += "Z" - else: - path = path[:-1] - return path + return Polyline( + [flinger.fling(x.coordinates) + shift for x in nodes] + ).get_path() diff --git a/map_machine/mapper.py b/map_machine/mapper.py index 1cead1b..6b978bc 100644 --- a/map_machine/mapper.py +++ b/map_machine/mapper.py @@ -15,14 +15,14 @@ from svgwrite.shapes import Rect from map_machine.boundary_box import BoundaryBox from map_machine.constructor import Constructor -from map_machine.figure import Road, StyledFigure +from map_machine.figure import StyledFigure 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 from map_machine.point import Occupied, Point -from map_machine.road import Intersection, RoadPart +from map_machine.road import Intersection, Road, RoadPart from map_machine.scheme import Scheme from map_machine.ui import BuildingMode, progress_bar from map_machine.workspace import workspace @@ -70,24 +70,7 @@ class Map: self.svg.add(path) progress_bar(-1, 0, text="Drawing ways") - layered_roads: dict[float, list[Road]] = {} - for road in constructor.roads: - if road.layer not in layered_roads: - layered_roads[road.layer] = [] - layered_roads[road.layer].append(road) - - for layer in sorted(layered_roads.keys()): - roads = sorted( - layered_roads[layer], key=lambda x: x.matcher.priority - ) - for road in roads: - road.draw(self.svg, self.flinger, road.matcher.border_color, 2) - for road in roads: - road.draw(self.svg, self.flinger, road.matcher.color) - for road in roads: - road.draw_lanes( - self.svg, self.flinger, road.matcher.border_color - ) + constructor.roads.draw(self.svg, self.flinger) for tree in constructor.trees: tree.draw(self.svg, self.flinger, self.scheme) @@ -218,6 +201,8 @@ def ui(arguments: argparse.Namespace) -> None: if arguments.input_file_names: input_file_names = list(map(Path, arguments.input_file_names)) + if arguments.boundary_box: + boundary_box = BoundaryBox.from_text(arguments.boundary_box) else: if arguments.boundary_box: boundary_box = BoundaryBox.from_text(arguments.boundary_box) diff --git a/map_machine/osm_reader.py b/map_machine/osm_reader.py index 2c71881..0945dc9 100644 --- a/map_machine/osm_reader.py +++ b/map_machine/osm_reader.py @@ -151,8 +151,8 @@ class OSMNode(Tagged): :param structure: input structure """ return cls( - structure["id"], structure["tags"] if "tags" in structure else {}, + structure["id"], coordinates=np.array((structure["lat"], structure["lon"])), ) @@ -209,7 +209,7 @@ class OSMWay(Tagged): :param nodes: node structure """ return cls( - structure["tags"], + structure["tags"] if "tags" in structure else {}, structure["id"], [nodes[x] for x in structure["nodes"]], ) diff --git a/map_machine/road.py b/map_machine/road.py index 0679c4d..0f3345c 100644 --- a/map_machine/road.py +++ b/map_machine/road.py @@ -2,13 +2,26 @@ WIP: road shape drawing. """ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional import numpy as np import svgwrite +from colour import Color +from svgwrite import Drawing from svgwrite.path import Path -from map_machine.vector import Line, compute_angle, norm, turn_by_angle +from map_machine.drawing import PathCommands +from map_machine.flinger import Flinger +from map_machine.osm_reader import OSMNode, Tagged +from map_machine.scheme import RoadMatcher + +from map_machine.vector import ( + Line, + Polyline, + compute_angle, + norm, + turn_by_angle, +) __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" @@ -345,3 +358,195 @@ class Intersection: # for part in self.parts: # part.draw_lanes(drawing, scale) drawing.add(drawing.path(inner_commands, fill="#FF8888")) + + +class Road(Tagged): + """ + Road or track on the map. + """ + + def __init__( + self, + tags: dict[str, str], + nodes: list[OSMNode], + matcher: RoadMatcher, + flinger: Flinger, + ) -> None: + super().__init__(tags) + self.nodes: list[OSMNode] = nodes + self.matcher: RoadMatcher = matcher + + self.line: Polyline = Polyline( + [flinger.fling(x.coordinates) for x in self.nodes] + ) + self.width: Optional[float] = 5 + self.lanes: list[Lane] = [] + + if "lanes" in tags: + try: + self.width = int(tags["lanes"]) * 3.7 + self.lanes = [Lane()] * int(tags["lanes"]) + except ValueError: + pass + + number: int + if "lanes:forward" in tags: + number = int(tags["lanes:forward"]) + [x.set_forward(True) for x in self.lanes[-number:]] + if "lanes:backward" in tags: + number = int(tags["lanes:backward"]) + [x.set_forward(False) for x in self.lanes[:number]] + + if "width" in tags: + try: + self.width = float(tags["width"]) + except ValueError: + pass + + self.layer: float = 0 + if "layer" in tags: + self.layer = float(tags["layer"]) + + def draw( + self, + svg: Drawing, + flinger: Flinger, + color: Color, + extra_width: float = 0, + ) -> None: + """Draw road as simple SVG path.""" + width: float + if self.width is not None: + width = self.width + else: + width = self.matcher.default_width + if extra_width and self.tags.get("bridge") == "yes": + color = Color("#666666") + scale: float = flinger.get_scale(self.nodes[0].coordinates) + path_commands: str = self.line.get_path() + path: Path = Path(d=path_commands) + style: dict[str, Any] = { + "fill": "none", + "stroke": color.hex, + "stroke-linecap": "butt", + "stroke-linejoin": "round", + "stroke-width": scale * width + extra_width, + } + path.update(style) + svg.add(path) + + def draw_lanes(self, svg: Drawing, flinger: Flinger, color: Color) -> None: + """Draw lane separators.""" + scale: float = flinger.get_scale(self.nodes[0].coordinates) + if len(self.lanes) < 2: + return + for index in range(1, len(self.lanes)): + parallel_offset: float = scale * ( + -self.width / 2 + index * self.width / len(self.lanes) + ) + path: Path = Path(d=self.line.get_path(parallel_offset)) + style: dict[str, Any] = { + "fill": "none", + "stroke": color.hex, + "stroke-linejoin": "round", + "stroke-width": 1, + "opacity": 0.5, + } + path.update(style) + svg.add(path) + + +def get_curve_points( + road: Road, scale: float, center: np.ndarray, road_end: np.ndarray +) -> list[np.ndarray]: + """ + :param road: road segment + :param scale: current zoom scale + :param center: road intersection point + :param road_end: end point of the road segment + """ + width: float = road.width / 2.0 * scale + 0.5 + + direction: np.ndarray = (road_end - center) / np.linalg.norm( + road_end - center + ) + left: np.ndarray = turn_by_angle(direction, np.pi / 2.0) * width + right: np.ndarray = turn_by_angle(direction, -np.pi / 2.0) * width + + return [road_end + left, center + left, center + right, road_end + right] + + +class Roads: + """ + Whole road structure. + """ + + def __init__(self) -> None: + self.roads: list[Road] = [] + self.connections: dict[int, list[tuple[Road, int]]] = {} + + def append(self, road: Road) -> None: + """Add road and update connections.""" + self.roads.append(road) + for index in road.nodes[0].id_, road.nodes[-1].id_: + if index not in self.connections: + self.connections[index] = [] + self.connections[road.nodes[0].id_].append((road, 0)) + self.connections[road.nodes[-1].id_].append((road, -1)) + + def draw(self, svg: Drawing, flinger: Flinger) -> None: + """Draw whole road system.""" + scale: float = flinger.get_scale(self.roads[0].nodes[0].coordinates) + layered_roads: dict[float, list[Road]] = {} + for road in self.roads: + if road.layer not in layered_roads: + layered_roads[road.layer] = [] + layered_roads[road.layer].append(road) + for index in 0, -1: + id_: int = road.nodes[index].id_ + if len(self.connections[id_]) != 2: + continue + road.line.shorten(index) + + for layer in sorted(layered_roads.keys()): + roads = sorted( + layered_roads[layer], key=lambda x: x.matcher.priority + ) + for road in roads: + road.draw(svg, flinger, road.matcher.border_color, 2) + for road in roads: + road.draw(svg, flinger, road.matcher.color) + for id_ in self.connections: + if len(self.connections[id_]) != 2: + continue + connected: list[tuple[Road, int]] = self.connections[id_] + road_1, index_1 = connected[0] + road_2, index_2 = connected[1] + node: OSMNode = road_1.nodes[index_1] + point = flinger.fling(node.coordinates) + + c1: PathCommands = get_curve_points( + road_1, scale, point, road_1.line.points[index_1] + ) + c2: PathCommands = get_curve_points( + road_2, scale, point, road_2.line.points[index_2] + ) + curve_1 = [c1[0], "C", c1[1], c2[2], c2[3]] + curve_2 = [c2[0], "C", c2[1], c1[2], c1[3]] + + path = svg.path( + d=["M"] + curve_1 + ["L"] + curve_2 + ["Z"], + fill=road_1.matcher.color.hex, + ) + svg.add(path) + + for curve in curve_1, curve_2: + path = svg.path( + d=["M"] + curve, + fill="none", + stroke=road_1.matcher.border_color.hex, + ) + svg.add(path) + + for road in roads: + road.draw_lanes(svg, flinger, road.matcher.border_color) diff --git a/map_machine/vector.py b/map_machine/vector.py index add72e4..14c03f6 100644 --- a/map_machine/vector.py +++ b/map_machine/vector.py @@ -6,6 +6,8 @@ import numpy as np __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" +from shapely.geometry import LineString + def compute_angle(vector: np.ndarray) -> float: """ @@ -38,6 +40,37 @@ def norm(vector: np.ndarray) -> np.ndarray: return vector / np.linalg.norm(vector) +class Polyline: + """ + List of connected points. + """ + + def __init__(self, points: list[np.ndarray]) -> None: + self.points: list[np.ndarray] = points + + def get_path(self, parallel_offset: float = 0) -> str: + """Construct SVG path commands.""" + points: list[np.ndarray] + try: + points = ( + LineString(self.points).parallel_offset(parallel_offset).coords + if parallel_offset + else self.points + ) + except ValueError: + points = self.points + path: str = "M " + " L ".join(f"{x[0]},{x[1]}" for x in points) + return path + (" Z" if np.allclose(points[0], points[-1]) else "") + + def shorten(self, index: int) -> None: + """Make shorten part specified with index.""" + index_2: int = 1 if index == 0 else -2 + diff: np.ndarray = self.points[index_2] - self.points[index] + self.points[index] = ( + self.points[index] + diff / np.linalg.norm(diff) * 5 + ) + + class Line: """Infinity line: Ax + By + C = 0."""