map-machine/map_machine/figure.py
Sergey Vartanov b715e12924 Add --coordinates and --size arguments.
Construct boundary box from center coordinates and size.
2021-09-16 07:44:41 +03:00

523 lines
16 KiB
Python

"""
Figures displayed on the map.
"""
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
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
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
BUILDING_HEIGHT_SCALE: float = 2.5
BUILDING_MINIMAL_HEIGHT: float = 8.0
class Figure(Tagged):
"""
Some figure on the map: way or area.
"""
def __init__(
self,
tags: dict[str, str],
inners: list[list[OSMNode]],
outers: list[list[OSMNode]],
) -> None:
super().__init__(tags)
self.inners: list[list[OSMNode]] = list(map(make_clockwise, inners))
self.outers: list[list[OSMNode]] = list(
map(make_counter_clockwise, outers)
)
def get_path(
self, flinger: Flinger, offset: np.ndarray = np.array((0, 0))
) -> str:
"""
Get SVG path commands.
:param flinger: converter for geo coordinates
:param offset: offset vector
"""
path: str = ""
for outer_nodes in self.outers:
path += f"{get_path(outer_nodes, offset, flinger)} "
for inner_nodes in self.inners:
path += f"{get_path(inner_nodes, offset, flinger)} "
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):
"""
Building on the map.
"""
def __init__(
self,
tags: dict[str, str],
inners: list[list[OSMNode]],
outers: list[list[OSMNode]],
flinger: Flinger,
scheme: Scheme,
) -> None:
super().__init__(tags, inners, outers)
style: dict[str, Any] = {
"fill": scheme.get_color("building_color").hex,
"stroke": scheme.get_color("building_border_color").hex,
}
self.line_style: LineStyle = LineStyle(style)
self.parts: list[Segment] = []
for nodes in self.inners + self.outers:
for i in range(len(nodes) - 1):
flung_1: np.ndarray = flinger.fling(nodes[i].coordinates)
flung_2: np.ndarray = flinger.fling(nodes[i + 1].coordinates)
self.parts.append(Segment(flung_1, flung_2))
self.parts = sorted(self.parts)
self.height: float = BUILDING_MINIMAL_HEIGHT
self.min_height: float = 0.0
levels: Optional[str] = self.get_float("building:levels")
if levels:
self.height = float(levels) * BUILDING_HEIGHT_SCALE
levels: Optional[str] = self.get_float("building:min_level")
if levels:
self.min_height = float(levels) * BUILDING_HEIGHT_SCALE
height: Optional[float] = self.get_length("height")
if height:
self.height = height
height: Optional[float] = self.get_length("min_height")
if height:
self.min_height = height
def draw(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw simple building shape."""
path: Path = Path(d=self.get_path(flinger))
path.update(self.line_style.style)
path.update({"stroke-linejoin": "round"})
svg.add(path)
def draw_shade(self, building_shade: Group, flinger: Flinger) -> None:
"""Draw shade casted by the building."""
scale: float = flinger.get_scale() / 3.0
shift_1: np.ndarray = np.array((scale * self.min_height, 0))
shift_2: np.ndarray = np.array((scale * self.height, 0))
commands: str = self.get_path(flinger, shift_1)
path: Path = Path(
d=commands, fill="#000000", stroke="#000000", stroke_width=1
)
building_shade.add(path)
for nodes in self.inners + self.outers:
for i in range(len(nodes) - 1):
flung_1 = flinger.fling(nodes[i].coordinates)
flung_2 = flinger.fling(nodes[i + 1].coordinates)
command: PathCommands = [
"M",
np.add(flung_1, shift_1),
"L",
np.add(flung_2, shift_1),
np.add(flung_2, shift_2),
np.add(flung_1, shift_2),
"Z",
]
path: Path = Path(
command, fill="#000000", stroke="#000000", stroke_width=1
)
building_shade.add(path)
def draw_walls(
self, svg: Drawing, height: float, previous_height: float, scale: float
) -> None:
"""Draw building walls."""
shift_1: np.ndarray = np.array((0, -previous_height * scale))
shift_2: np.ndarray = np.array((0, -height * scale))
for segment in self.parts:
fill: Color
if height == 2:
fill = Color("#AAAAAA")
elif height == 4:
fill = Color("#C3C3C3")
else:
color_part: float = 0.8 + segment.angle * 0.2
fill = Color(rgb=(color_part, color_part, color_part))
command = (
"M",
segment.point_1 + shift_1,
"L",
segment.point_2 + shift_1,
segment.point_2 + shift_2,
segment.point_1 + shift_2,
segment.point_1 + shift_1,
"Z",
)
path: Path = svg.path(
d=command,
fill=fill.hex,
stroke=fill.hex,
stroke_width=1,
stroke_linejoin="round",
)
svg.add(path)
def draw_roof(self, svg: Drawing, flinger: Flinger, scale: float) -> None:
"""Draw building roof."""
path: Path = Path(
d=self.get_path(flinger, np.array([0, -self.height * scale]))
)
path.update(self.line_style.style)
path.update({"stroke-linejoin": "round"})
svg.add(path)
class StyledFigure(Figure):
"""
Figure with stroke and fill style.
"""
def __init__(
self,
tags: dict[str, str],
inners: list[list[OSMNode]],
outers: list[list[OSMNode]],
line_style: LineStyle,
) -> None:
super().__init__(tags, inners, outers)
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.
"""
def __init__(
self, tags: dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw crater ridge."""
scale: float = flinger.get_scale(self.coordinates)
assert "diameter" in self.tags
radius: float = float(self.tags["diameter"]) / 2.0
radial_gradient = svg.radialGradient(
center=self.point + np.array((0, radius * scale / 7)),
r=radius * scale,
gradientUnits="userSpaceOnUse",
)
color: Color = Color("#000000")
gradient = svg.defs.add(radial_gradient)
(
gradient
.add_stop_color(0, color.hex, opacity=0.2)
.add_stop_color(0.7, color.hex, opacity=0.2)
.add_stop_color(1, color.hex, opacity=1)
) # fmt: skip
circle = svg.circle(
self.point,
radius * scale,
fill=gradient.get_paint_server(),
opacity=0.2,
)
svg.add(circle)
class Tree(Tagged):
"""
Tree on the map.
"""
def __init__(
self, tags: dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme) -> None:
"""Draw crown and trunk."""
scale: float = flinger.get_scale(self.coordinates)
radius: float
if "diameter_crown" in self.tags:
radius = float(self.tags["diameter_crown"]) / 2.0
else:
radius = 2.0
color: Color = scheme.get_color("evergreen_color")
svg.add(svg.circle(self.point, radius * scale, fill=color, opacity=0.3))
if "circumference" in self.tags:
radius: float = float(self.tags["circumference"]) / 2.0 / np.pi
svg.add(svg.circle(self.point, radius * scale, fill="#B89A74"))
class DirectionSector(Tagged):
"""
Sector that represents direction.
"""
def __init__(self, tags: dict[str, str], point: np.ndarray) -> None:
super().__init__(tags)
self.point: np.ndarray = point
def draw(self, svg: Drawing, scheme: Scheme) -> None:
"""Draw gradient sector."""
angle: Optional[float] = None
is_revert_gradient: bool = False
direction: str
direction_radius: float
direction_color: Color
if self.get_tag("man_made") == "surveillance":
direction = self.get_tag("camera:direction")
if "camera:angle" in self.tags:
angle = float(self.get_tag("camera:angle"))
if "angle" in self.tags:
angle = float(self.get_tag("angle"))
direction_radius = 50
direction_color = scheme.get_color("direction_camera_color")
elif self.get_tag("traffic_sign") == "stop":
direction = self.get_tag("direction")
direction_radius = 25
direction_color = Color("red")
else:
direction = self.get_tag("direction")
direction_radius = 50
direction_color = scheme.get_color("direction_view_color")
is_revert_gradient = True
if not direction:
return
point: np.ndarray = (self.point.astype(int)).astype(float)
paths: Iterator[PathCommands]
if angle is not None:
paths = [Sector(direction, angle).draw(point, direction_radius)]
else:
paths = DirectionSet(direction).draw(point, direction_radius)
for path in paths:
radial_gradient = svg.radialGradient(
center=point,
r=direction_radius,
gradientUnits="userSpaceOnUse",
)
gradient = svg.defs.add(radial_gradient)
if is_revert_gradient:
(
gradient
.add_stop_color(0, direction_color.hex, opacity=0)
.add_stop_color(1, direction_color.hex, opacity=0.7)
) # fmt: skip
else:
(
gradient
.add_stop_color(0, direction_color.hex, opacity=0.4)
.add_stop_color(1, direction_color.hex, opacity=0)
) # fmt: skip
path_element: Path = svg.path(
d=["M", point] + path + ["L", point, "Z"],
fill=gradient.get_paint_server(),
)
svg.add(path_element)
class Segment:
"""
Line segment.
"""
def __init__(self, point_1: np.ndarray, point_2: np.ndarray) -> None:
self.point_1: np.ndarray = point_1
self.point_2: np.ndarray = point_2
difference: np.ndarray = point_2 - point_1
vector: np.ndarray = difference / np.linalg.norm(difference)
self.angle: float = np.arccos(np.dot(vector, np.array((0, 1)))) / np.pi
def __lt__(self, other: "Segment") -> bool:
return (
((self.point_1 + self.point_2) / 2)[1]
< ((other.point_1 + other.point_2) / 2)[1]
) # fmt: skip
def is_clockwise(polygon: list[OSMNode]) -> bool:
"""
Return true if polygon nodes are in clockwise order.
:param polygon: list of OpenStreetMap nodes
"""
count: float = 0
for index, node in enumerate(polygon):
next_index: int = 0 if index == len(polygon) - 1 else index + 1
count += (polygon[next_index].coordinates[0] - node.coordinates[0]) * (
polygon[next_index].coordinates[1] + node.coordinates[1]
)
return count >= 0
def make_clockwise(polygon: list[OSMNode]) -> list[OSMNode]:
"""
Make polygon nodes clockwise.
:param polygon: list of OpenStreetMap nodes
"""
return polygon if is_clockwise(polygon) else list(reversed(polygon))
def make_counter_clockwise(polygon: list[OSMNode]) -> list[OSMNode]:
"""
Make polygon nodes counter-clockwise.
:param polygon: list of OpenStreetMap nodes
"""
return polygon if not is_clockwise(polygon) else list(reversed(polygon))
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