Add options to tile generation.

This commit is contained in:
Sergey Vartanov 2021-08-25 23:56:49 +03:00
parent 163fe4060b
commit fe2714c927
26 changed files with 448 additions and 524 deletions

View file

@ -18,7 +18,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest flake8 black
pip install black flake8 pytest
pip install -r requirements.txt
pip install .
- name: Test with pytest

View file

@ -81,7 +81,7 @@ The central feature of the project is Röntgen icon set. It is a set of monochro
All icons tend to support common design style, which is heavily inspired by [Maki](https://github.com/mapbox/maki), [Osmic](https://github.com/gmgeo/osmic), and [Temaki](https://github.com/ideditor/temaki).
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Röntgen can use more than one icon to visualize an entity and use colors to visualize [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) value or other entity properties (like material or genus).
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Röntgen can use more than one icon to visualize an entity and use colors to visualize [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) value or other entity properties (like [`material`](https://wiki.openstreetmap.org/wiki/Key:material) or [`genus`](https://wiki.openstreetmap.org/wiki/Key:genus)).
![Icons](doc/grid.png)
@ -217,7 +217,7 @@ Example:
roentgen tile -b 2.361,48.871,2.368,48.875
```
will generate 36 PNG tiles at scale 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files `cache/2.360,48.869,2.370,48.877.svg` and `cache/2.360,48.869,2.370,48.877.png`.
will generate 36 PNG tiles at scale 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files `cache/2.360,48.869,2.370,48.877_18.svg` and `cache/2.360,48.869,2.370,48.877_18.png`.
Tile server
-----------

View file

@ -4,7 +4,7 @@
\b {Röntgen} (or \b {Roentgen} when ASCII is preferred) project consists of
\list
{simple Python \ref {http://openstreetmap.org} {OpenStreetMap} renderer (see \ref {#usage} {usage}, \ref {#map-generation} {renderer documentation}),}
{simple Python \ref {http://openstreetmap.org} {OpenStreetMap} renderer and tile generator (see \ref {#usage-example} {usage}, \ref {#map-generation} {renderer documentation}, \ref {#tile-generation} {tile generation}),}
{\ref {#icon-set} {set of CC-BY 4.0 icons} that can be used outside the project.}
The idea behind the Röntgen project is to \b {show all the richness of the OpenStreetMap data}\: to have a possibility to \i {display any map feature} represented by OpenStreetMap data tags by means of colors, shapes, and icons. Röntgen is created for OpenStreetMap contributors\: to display all changes one made on the map even if they are small, and for users\: to dig down into the map and find every detail that was mapped.
@ -93,7 +93,7 @@ The central feature of the project is Röntgen icon set. It is a set of monochr
All icons tend to support common design style, which is heavily inspired by \ref {https://github.com/mapbox/maki} {Maki}, \ref {https://github.com/gmgeo/osmic} {Osmic}, and \ref {https://github.com/ideditor/temaki} {Temaki}.
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Röntgen can use more than one icon to visualize an entity and use colors to visualize \osm {colour} value or other entity properties (like material or genus).
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Röntgen can use more than one icon to visualize an entity and use colors to visualize \osm {colour} value or other entity properties (like \osm {material} or \osm {genus}).
\image {doc/grid.png} {Icons}
@ -230,7 +230,7 @@ Example\:
\code {roentgen tile -b 2.361,48.871,2.368,48.875} {bash}
will generate 36 PNG tiles at scale 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files \m {cache/2.360,48.869,2.370,48.877.svg} and \m {cache/2.360,48.869,2.370,48.877.png}.
will generate 36 PNG tiles at scale 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files \m {cache/2.360,48.869,2.370,48.877_18.svg} and \m {cache/2.360,48.869,2.370,48.877_18.png}.
\2 {Tile server} {tile-server}

View file

@ -1,3 +1,6 @@
"""
Rectangle that limit space on the map.
"""
import logging
import re
from dataclasses import dataclass

View file

@ -21,7 +21,7 @@ from roentgen.icon import (
from roentgen.osm_reader import OSMData, OSMNode, OSMRelation, OSMWay
from roentgen.point import Point
from roentgen.scheme import DEFAULT_COLOR, LineStyle, Scheme
from roentgen.ui import AUTHOR_MODE, TIME_MODE
from roentgen.ui import AUTHOR_MODE, BuildingMode, TIME_MODE
from roentgen.util import MinMax
# fmt: on
@ -129,17 +129,25 @@ class Constructor:
flinger: Flinger,
scheme: Scheme,
icon_extractor: ShapeExtractor,
check_level=lambda x: True,
mode: str = "normal",
seed: str = "",
options,
) -> None:
self.check_level = check_level
self.mode: str = mode
self.seed: str = seed
self.osm_data: OSMData = osm_data
self.flinger: Flinger = flinger
self.scheme: Scheme = scheme
self.icon_extractor = icon_extractor
self.options = options
if options.level:
if options.level == "overground":
self.check_level = check_level_overground
elif options.level == "underground":
self.check_level = lambda x: not check_level_overground(x)
else:
self.check_level = lambda x: not check_level_number(
x, float(options.level)
)
else:
self.check_level = lambda x: True
self.points: list[Point] = []
self.figures: list[StyledFigure] = []
@ -195,10 +203,10 @@ class Constructor:
return
center_point, center_coordinates = line_center(outers[0], self.flinger)
if self.mode in [AUTHOR_MODE, TIME_MODE]:
if self.options.mode in [AUTHOR_MODE, TIME_MODE]:
color: Color
if self.mode == AUTHOR_MODE:
color = get_user_color(line.user, self.seed)
if self.options.mode == AUTHOR_MODE:
color = get_user_color(line.user, self.options.seed)
else: # self.mode == TIME_MODE
color = get_time_color(line.timestamp, self.osm_data.time)
self.draw_special_mode(inners, line, outers, color)
@ -207,7 +215,11 @@ class Constructor:
if not line.tags:
return
if "building:part" in line.tags or "building" in line.tags:
building_mode: BuildingMode = BuildingMode(self.options.buildings)
if "building" in line.tags or (
building_mode == BuildingMode.ISOMETRIC
and "building:part" in line.tags
):
self.add_building(
Building(line.tags, inners, outers, self.flinger, self.scheme)
)
@ -276,9 +288,7 @@ class Constructor:
self.points.append(point)
def draw_special_mode(self, inners, line, outers, color) -> None:
"""
Add figure for special mode: time or author.
"""
"""Add figure for special mode: time or author."""
style: dict[str, Any] = {
"fill": "none",
"stroke": color.hex,
@ -289,9 +299,7 @@ class Constructor:
)
def construct_relations(self) -> None:
"""
Construct Röntgen ways from OSM relations.
"""
"""Construct Röntgen ways from OSM relations."""
for relation_id in self.osm_data.relations:
relation: OSMRelation = self.osm_data.relations[relation_id]
tags = relation.tags
@ -344,13 +352,13 @@ class Constructor:
icon_set: IconSet
draw_outline: bool = True
if self.mode in [TIME_MODE, AUTHOR_MODE]:
if self.options.mode in [TIME_MODE, AUTHOR_MODE]:
if not tags:
return
color: Color = DEFAULT_COLOR
if self.mode == AUTHOR_MODE:
color = get_user_color(node.user, self.seed)
if self.mode == TIME_MODE:
if self.options.mode == AUTHOR_MODE:
color = get_user_color(node.user, self.options.seed)
if self.options.mode == TIME_MODE:
color = get_time_color(node.timestamp, self.osm_data.time)
dot = self.icon_extractor.get_shape(DEFAULT_SMALL_SHAPE_ID)
icon_set = IconSet(
@ -382,3 +390,37 @@ class Constructor:
priority=priority, draw_outline=draw_outline
) # fmt: skip
self.points.append(point)
def check_level_number(tags: dict[str, Any], level: float):
"""Check if element described by tags is no the specified level."""
if "level" in tags:
levels = map(float, tags["level"].replace(",", ".").split(";"))
if level not in levels:
return False
else:
return False
return True
def check_level_overground(tags: dict[str, Any]) -> bool:
"""Check if element described by tags is overground."""
if "level" in tags:
try:
levels = map(float, tags["level"].replace(",", ".").split(";"))
for level in levels:
if level <= 0:
return False
except ValueError:
pass
if "layer" in tags:
try:
levels = map(float, tags["layer"].replace(",", ".").split(";"))
for level in levels:
if level <= 0:
return False
except ValueError:
pass
if "parking" in tags and tags["parking"] == "underground":
return False
return True

View file

@ -17,9 +17,7 @@ DEFAULT_ANGLE: float = np.pi / 30
def degree_to_radian(degree: float) -> float:
"""
Convert value in degrees to radians.
"""
"""Convert value in degrees to radians."""
return degree / 180 * np.pi

View file

@ -1,3 +1,6 @@
"""
Drawing separate map elements.
"""
import logging
import sys
from pathlib import Path
@ -5,10 +8,10 @@ from pathlib import Path
import numpy as np
import svgwrite
from roentgen.workspace import workspace
from roentgen.icon import ShapeExtractor
from roentgen.point import Point
from roentgen.scheme import LineStyle, Scheme
from roentgen.workspace import workspace
def draw_element(options) -> None:

View file

@ -109,6 +109,13 @@ class Building(Figure):
if height:
self.min_height = height
def draw(self, svg: Drawing, flinger: Flinger):
"""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, flinger: Flinger) -> None:
"""Draw shade casted by the building."""
scale: float = flinger.get_scale() / 3.0

View file

@ -5,7 +5,7 @@ from typing import Optional
import numpy as np
from roentgen.util import MinMax
from roentgen.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -45,7 +45,7 @@ class Flinger:
def __init__(
self,
geo_boundaries: MinMax,
geo_boundaries: BoundaryBox,
scale: float = 18,
border: np.array = np.array((0, 0)),
) -> None:
@ -53,14 +53,14 @@ class Flinger:
:param geo_boundaries: minimum and maximum latitude and longitude
:param scale: OSM zoom level
"""
self.geo_boundaries: MinMax = geo_boundaries
self.geo_boundaries: BoundaryBox = geo_boundaries
self.border = border
self.ratio: float = (
osm_zoom_level_to_pixels_per_meter(scale) * EQUATOR_LENGTH / 360
)
self.size: np.array = border * 2 + self.ratio * (
pseudo_mercator(self.geo_boundaries.max_)
- pseudo_mercator(self.geo_boundaries.min_)
pseudo_mercator(self.geo_boundaries.max_())
- pseudo_mercator(self.geo_boundaries.min_())
)
self.pixels_per_meter = osm_zoom_level_to_pixels_per_meter(scale)
@ -74,7 +74,7 @@ class Flinger:
"""
result: np.array = self.border + self.ratio * (
pseudo_mercator(coordinates)
- pseudo_mercator(self.geo_boundaries.min_)
- pseudo_mercator(self.geo_boundaries.min_())
)
# Invert y axis on coordinate plane.

View file

@ -48,9 +48,7 @@ class IconCollection:
icons: list[Icon] = []
def add() -> Icon:
"""
Construct icon and add it to the list.
"""
"""Construct icon and add it to the list."""
specifications = [
ShapeSpecification.from_structure(x, extractor, scheme)
for x in current_set

View file

@ -118,9 +118,7 @@ class Shape:
def parse_length(text: str) -> float:
"""
Parse length from SVG attribute.
"""
"""Parse length from SVG attribute."""
if text.endswith("px"):
text = text[:-2]
return float(text)
@ -319,9 +317,7 @@ class ShapeSpecification:
)
def is_default(self) -> bool:
"""
Check whether shape is default.
"""
"""Check whether shape is default."""
return self.shape.id_ == DEFAULT_SHAPE_ID
def draw(
@ -389,15 +385,11 @@ class Icon:
shape_specifications: list[ShapeSpecification]
def get_shape_ids(self) -> list[str]:
"""
Get all shape identifiers in the icon.
"""
"""Get all shape identifiers in the icon."""
return [x.shape.id_ for x in self.shape_specifications]
def get_names(self) -> list[str]:
"""
Gat all shape names in the icon.
"""
"""Get all shape names in the icon."""
return [
(x.shape.name if x.shape.name else "unknown")
for x in self.shape_specifications
@ -462,15 +454,11 @@ class Icon:
svg.write(output_file)
def is_default(self) -> bool:
"""
Check whether first shape is default.
"""
"""Check whether first shape is default."""
return self.shape_specifications[0].is_default()
def recolor(self, color: Color, white: Optional[Color] = None) -> None:
"""
Paint all shapes in the color.
"""
"""Paint all shapes in the color."""
for shape_specification in self.shape_specifications:
if shape_specification.color == Color("white") and white:
shape_specification.color = white
@ -480,9 +468,7 @@ class Icon:
def add_specifications(
self, specifications: list[ShapeSpecification]
) -> None:
"""
Add shape specifications to the icon.
"""
"""Add shape specifications to the icon."""
self.shape_specifications += specifications
def __eq__(self, other) -> bool:

View file

@ -1,23 +1,16 @@
"""
Röntgen entry point.
Author: Sergey Vartanov (me@enzet.ru).
"""
import argparse
import logging
import sys
from pathlib import Path
from roentgen.element import draw_element
from roentgen.grid import draw_icons
from roentgen.scheme import Scheme
from roentgen.ui import parse_options
from roentgen.workspace import Workspace
def init_scheme(workspace: Workspace) -> Scheme:
"""Initialize default scheme."""
return Scheme(workspace.DEFAULT_SCHEME_PATH)
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
def main() -> None:
@ -38,6 +31,8 @@ def main() -> None:
tile.ui(arguments)
elif arguments.command == "icons":
from roentgen.grid import draw_icons
draw_icons()
elif arguments.command == "mapcss":
@ -46,6 +41,8 @@ def main() -> None:
mapcss.ui(arguments)
elif arguments.command == "element":
from roentgen.element import draw_element
draw_element(arguments)
elif arguments.command == "server":
@ -54,9 +51,10 @@ def main() -> None:
server.ui(arguments)
elif arguments.command == "taginfo":
from roentgen.scheme import Scheme
from roentgen.taginfo import write_taginfo_project_file
write_taginfo_project_file(init_scheme(workspace))
write_taginfo_project_file(Scheme(workspace.DEFAULT_SCHEME_PATH))
if __name__ == "__main__":

View file

@ -140,9 +140,7 @@ class MapCSSWriter:
return selector
def write(self, output_file: TextIO) -> None:
"""
Construct icon selectors for MapCSS 0.2 scheme.
"""
"""Construct icon selectors for MapCSS 0.2 scheme."""
output_file.write(HEADER + "\n\n")
if self.add_ways:
@ -178,9 +176,7 @@ class MapCSSWriter:
def ui(options) -> None:
"""
Write MapCSS 0.2 scheme.
"""
"""Write MapCSS 0.2 scheme."""
directory: Path = workspace.get_mapcss_path()
icons_with_outline_path: Path = workspace.get_mapcss_icons_path()

View file

@ -1,10 +1,10 @@
"""
Simple OpenStreetMap renderer.
"""
import logging
from pathlib import Path
from typing import Any, Iterator
import logging
import numpy as np
import svgwrite
from colour import Color
@ -12,17 +12,17 @@ from svgwrite.container import Group
from svgwrite.path import Path as SVGPath
from svgwrite.shapes import Rect
from roentgen.icon import ShapeExtractor
from roentgen.osm_getter import NetworkError, get_osm
from roentgen.boundary_box import BoundaryBox
from roentgen.constructor import Constructor
from roentgen.figure import Road
from roentgen.flinger import Flinger
from roentgen.icon import ShapeExtractor
from roentgen.osm_getter import NetworkError, get_osm
from roentgen.osm_reader import OSMData, OSMNode, OSMReader, OverpassReader
from roentgen.point import Occupied
from roentgen.road import Intersection, RoadPart
from roentgen.scheme import Scheme
from roentgen.ui import AUTHOR_MODE, BoundaryBox, TIME_MODE, progress_bar
from roentgen.util import MinMax
from roentgen.ui import AUTHOR_MODE, BuildingMode, TIME_MODE, progress_bar
from roentgen.workspace import workspace
__author__ = "Sergey Vartanov"
@ -35,24 +35,15 @@ class Map:
"""
def __init__(
self,
flinger: Flinger,
svg: svgwrite.Drawing,
scheme: Scheme,
overlap: int = 12,
mode: str = "normal",
label_mode: str = "main",
self, flinger: Flinger, svg: svgwrite.Drawing, scheme: Scheme, options
) -> None:
self.overlap: int = overlap
self.mode: str = mode
self.label_mode: str = label_mode
self.flinger: Flinger = flinger
self.svg: svgwrite.Drawing = svg
self.scheme: Scheme = scheme
self.options = options
self.background_color: Color = self.scheme.get_color("background_color")
if self.mode in [AUTHOR_MODE, TIME_MODE]:
if self.options.mode in [AUTHOR_MODE, TIME_MODE]:
self.background_color: Color = Color("#111111")
def draw(self, constructor: Constructor) -> None:
@ -89,11 +80,11 @@ class Map:
# All other points
if self.overlap == 0:
if self.options.overlap == 0:
occupied = None
else:
occupied = Occupied(
self.flinger.size[0], self.flinger.size[1], self.overlap
self.flinger.size[0], self.flinger.size[1], self.options.overlap
)
nodes = sorted(constructor.points, key=lambda x: -x.priority)
@ -114,17 +105,24 @@ class Map:
steps * 2 + index, steps * 3, step=10, text="Drawing texts"
)
if (
self.mode not in [TIME_MODE, AUTHOR_MODE]
and self.label_mode != "no"
self.options.mode not in [TIME_MODE, AUTHOR_MODE]
and self.options.label_mode != "no"
):
point.draw_texts(self.svg, occupied, self.label_mode)
point.draw_texts(self.svg, occupied, self.options.label_mode)
progress_bar(-1, len(nodes), step=10, text="Drawing nodes")
def draw_buildings(self, constructor: Constructor) -> None:
"""Draw buildings: shade, walls, and roof."""
building_shade: Group = Group(opacity=0.1)
building_mode: BuildingMode = BuildingMode(self.options.buildings)
if building_mode == BuildingMode.FLAT:
for building in constructor.buildings:
building.draw(self.svg, self.flinger)
return
scale: float = self.flinger.get_scale() / 3.0
building_shade: Group = Group(opacity=0.1)
for building in constructor.buildings:
building.draw_shade(building_shade, self.flinger)
self.svg.add(building_shade)
@ -150,9 +148,7 @@ class Map:
def draw_road(
self, road: Road, color: Color, extra_width: float = 0
) -> None:
"""
Draw road as simple SVG path.
"""
"""Draw road as simple SVG path."""
self.flinger.get_scale()
if road.width is not None:
width = road.width
@ -172,9 +168,7 @@ class Map:
self.svg.add(path)
def draw_roads(self, roads: Iterator[Road]) -> None:
"""
Draw road as simple SVG path.
"""
"""Draw road as simple SVG path."""
nodes: dict[OSMNode, set[RoadPart]] = {}
for road in roads:
@ -203,44 +197,6 @@ class Map:
intersection.draw(self.svg, True)
def check_level_number(tags: dict[str, Any], level: float):
"""
Check if element described by tags is no the specified level.
"""
if "level" in tags:
levels = map(float, tags["level"].replace(",", ".").split(";"))
if level not in levels:
return False
else:
return False
return True
def check_level_overground(tags: dict[str, Any]) -> bool:
"""
Check if element described by tags is overground.
"""
if "level" in tags:
try:
levels = map(float, tags["level"].replace(",", ".").split(";"))
for level in levels:
if level <= 0:
return False
except ValueError:
pass
if "layer" in tags:
try:
levels = map(float, tags["layer"].replace(",", ".").split(";"))
for level in levels:
if level <= 0:
return False
except ValueError:
pass
if "parking" in tags and tags["parking"] == "underground":
return False
return True
def ui(options) -> None:
"""
Röntgen entry point.
@ -276,27 +232,21 @@ def ui(options) -> None:
min_: np.array
max_: np.array
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 = reader.osm_data
view_box = MinMax(
np.array(
(osm_data.boundary_box[0].min_, osm_data.boundary_box[1].min_)
),
np.array(
(osm_data.boundary_box[0].max_, osm_data.boundary_box[1].max_)
),
)
view_box = boundary_box
else:
is_full: bool = options.mode in [AUTHOR_MODE, TIME_MODE]
osm_reader = OSMReader(is_full=is_full)
for file_name in input_file_names:
if not file_name.is_file():
print(f"Fatal: no such file: {file_name}.")
logging.fatal(f"No such file: {file_name}.")
exit(1)
osm_reader.parse_osm_file(file_name)
@ -304,10 +254,7 @@ def ui(options) -> None:
osm_data = osm_reader.osm_data
if options.boundary_box:
view_box = MinMax(
np.array((boundary_box.bottom, boundary_box.left)),
np.array((boundary_box.top, boundary_box.right)),
)
view_box = boundary_box
else:
view_box = osm_data.view_box
@ -321,48 +268,18 @@ def ui(options) -> None:
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
if options.level:
if options.level == "overground":
check_level = check_level_overground
elif options.level == "underground":
def check_level(x) -> bool:
"""Draw underground objects."""
return not check_level_overground(x)
else:
def check_level(x) -> bool:
"""Draw objects on the specified level."""
return not check_level_number(x, float(options.level))
else:
def check_level(_) -> bool:
"""Draw objects on any level."""
return True
constructor: Constructor = Constructor(
osm_data,
flinger,
scheme,
icon_extractor,
check_level,
options.mode,
options.seed,
osm_data=osm_data,
flinger=flinger,
scheme=scheme,
icon_extractor=icon_extractor,
options=options,
)
constructor.construct()
painter: Map = Map(
overlap=options.overlap,
mode=options.mode,
label_mode=options.label_mode,
flinger=flinger,
svg=svg,
scheme=scheme,
)
painter: Map = Map(flinger=flinger, svg=svg, scheme=scheme, options=options)
painter.draw(constructor)
print(f"Writing output SVG to {options.output_file_name}...")
logging.info(f"Writing output SVG to {options.output_file_name}...")
with open(options.output_file_name, "w") as output_file:
svg.write(output_file)

View file

@ -9,7 +9,7 @@ from pathlib import Path
import urllib3
from roentgen.ui import BoundaryBox
from roentgen.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"

View file

@ -11,6 +11,7 @@ from xml.etree import ElementTree
import numpy as np
from roentgen.boundary_box import BoundaryBox
from roentgen.util import MinMax
__author__ = "Sergey Vartanov"
@ -37,9 +38,7 @@ STAGES_OF_DECAY: list[str] = [
def parse_float(string: str) -> Optional[float]:
"""
Parse string representation of a float or integer value.
"""
"""Parse string representation of a float or integer value."""
try:
return float(string)
except (TypeError, ValueError):
@ -108,7 +107,7 @@ class OSMNode(Tagged):
super().__init__()
self.id_: Optional[int] = None
self.coordinates: Optional[np.array] = None
self.coordinates: Optional[np.ndarray] = None
self.visible: Optional[str] = None
self.changeset: Optional[str] = None
@ -118,10 +117,8 @@ class OSMNode(Tagged):
@classmethod
def from_xml_structure(cls, element, is_full: bool = False) -> "OSMNode":
"""
Parse node from OSM XML `<node>` element.
"""
node = cls()
"""Parse node from OSM XML `<node>` element."""
node: "OSMNode" = cls()
attributes = element.attrib
node.id_ = int(attributes["id"])
node.coordinates = np.array(
@ -178,9 +175,7 @@ class OSMWay(Tagged):
@classmethod
def from_xml_structure(cls, element, nodes, is_full: bool) -> "OSMWay":
"""
Parse way from OSM XML `<way>` element.
"""
"""Parse way from OSM XML `<way>` element."""
way = cls(int(element.attrib["id"]))
if is_full:
way.visible = element.attrib["visible"]
@ -215,15 +210,11 @@ class OSMWay(Tagged):
return self
def is_cycle(self) -> bool:
"""
Is way a cycle way or an area boundary.
"""
"""Is way a cycle way or an area boundary."""
return self.nodes[0] == self.nodes[-1]
def try_to_glue(self, other: "OSMWay") -> Optional["OSMWay"]:
"""
Create new combined way if ways share endpoints.
"""
"""Create new combined way if ways share endpoints."""
if self.nodes[0] == other.nodes[0]:
return OSMWay(nodes=list(reversed(other.nodes[1:])) + self.nodes)
if self.nodes[0] == other.nodes[-1]:
@ -255,9 +246,7 @@ class OSMRelation(Tagged):
@classmethod
def from_xml_structure(cls, element, is_full: bool) -> "OSMRelation":
"""
Parse relation from OSM XML `<relation>` element.
"""
"""Parse relation from OSM XML `<relation>` element."""
attributes = element.attrib
relation = cls(int(attributes["id"]))
if is_full:
@ -321,33 +310,24 @@ class OSMData:
self.authors: set[str] = set()
self.time: MinMax = MinMax()
self.boundary_box: list[MinMax] = [MinMax(), MinMax()]
self.view_box = None
self.view_box: Optional[BoundaryBox] = None
def add_node(self, node: OSMNode) -> None:
"""
Add node and update map parameters.
"""
"""Add node and update map parameters."""
self.nodes[node.id_] = node
if node.user:
self.authors.add(node.user)
self.time.update(node.timestamp)
self.boundary_box[0].update(node.coordinates[0])
self.boundary_box[1].update(node.coordinates[1])
def add_way(self, way: OSMWay) -> None:
"""
Add way and update map parameters.
"""
"""Add way and update map parameters."""
self.ways[way.id_] = way
if way.user:
self.authors.add(way.user)
self.time.update(way.timestamp)
def add_relation(self, relation: OSMRelation) -> None:
"""
Add relation and update map parameters.
"""
"""Add relation and update map parameters."""
self.relations[relation.id_] = relation
@ -362,9 +342,7 @@ class OverpassReader:
self.osm_data = OSMData()
def parse_json_file(self, file_name: Path) -> OSMData:
"""
Parse JSON structure from the file and construct map.
"""
"""Parse JSON structure from the file and construct map."""
with file_name.open() as input_file:
structure = json.load(input_file)
@ -460,15 +438,11 @@ class OSMReader:
return self.osm_data
def parse_bounds(self, element) -> None:
"""
Parse view box from XML element.
"""
"""Parse view box from XML element."""
attributes = element.attrib
self.osm_data.view_box = MinMax(
np.array(
(float(attributes["minlat"]), float(attributes["minlon"]))
),
np.array(
(float(attributes["maxlat"]), float(attributes["maxlon"]))
),
self.osm_data.view_box = BoundaryBox(
float(attributes["minlon"]),
float(attributes["minlat"]),
float(attributes["maxlon"]),
float(attributes["maxlat"]),
)

View file

@ -30,17 +30,13 @@ class Occupied:
self.overlap: float = overlap
def check(self, point: np.array) -> bool:
"""
Check whether point is already occupied by other elements.
"""
"""Check whether point is already occupied by other elements."""
if 0 <= point[0] < self.width and 0 <= point[1] < self.height:
return self.matrix[point[0], point[1]]
return True
def register(self, point) -> None:
"""
Register that point is occupied by an element.
"""
"""Register that point is occupied by an element."""
if 0 <= point[0] < self.width and 0 <= point[1] < self.height:
self.matrix[point[0], point[1]] = True
assert self.matrix[point[0], point[1]]
@ -86,9 +82,7 @@ class Point(Tagged):
def draw_main_shapes(
self, svg: svgwrite.Drawing, occupied: Optional[Occupied] = None
) -> None:
"""
Draw main shape for one node.
"""
"""Draw main shape for one node."""
keys_left = [x for x in self.tags.keys() if x not in self.processed]
if (
self.icon_set.main_icon.is_default()
@ -107,9 +101,7 @@ class Point(Tagged):
def draw_extra_shapes(
self, svg: svgwrite.Drawing, occupied: Optional[Occupied] = None
) -> None:
"""
Draw secondary shapes.
"""
"""Draw secondary shapes."""
if not self.icon_set.extra_icons or not self.main_icon_painted:
return
@ -141,9 +133,7 @@ class Point(Tagged):
occupied,
tags: Optional[dict[str, str]] = None,
) -> bool:
"""
Draw one combined icon and its outline.
"""
"""Draw one combined icon and its outline."""
# Down-cast floats to integers to make icons pixel-perfect.
position = list(map(int, position))
@ -173,9 +163,7 @@ class Point(Tagged):
occupied: Optional[Occupied] = None,
label_mode: str = "main",
) -> None:
"""
Draw all labels.
"""
"""Draw all labels."""
labels: list[Label]
if label_mode == "main":

View file

@ -81,14 +81,13 @@ class RoadPart:
self.left_outer = None
self.right_outer = None
self.point_a = None
self.point_middle = None
@classmethod
def from_nodes(
cls, node_1: OSMNode, node_2: OSMNode, flinger: Flinger, road, scale
) -> "RoadPart":
"""
Construct road part from OSM nodes.
"""
"""Construct road part from OSM nodes."""
lanes = [Lane(road.width / road.lanes)] * road.lanes
return cls(
@ -99,9 +98,7 @@ class RoadPart:
)
def update(self) -> None:
"""
Compute additional points.
"""
"""Compute additional points."""
if self.left_connection is not None:
self.right_projection = (
self.left_connection + self.right_vector - self.left_vector
@ -136,15 +133,11 @@ class RoadPart:
self.point_a = self.point_middle
def get_angle(self) -> float:
"""
Get an angle between line and x axis.
"""
"""Get an angle between line and x axis."""
return compute_angle(self.point_2 - self.point_1)
def draw_normal(self, drawing: svgwrite.Drawing):
"""
Draw some debug lines.
"""
"""Draw some debug lines."""
line = drawing.path(
("M", self.point_1, "L", self.point_2),
fill="none",
@ -154,9 +147,7 @@ class RoadPart:
drawing.add(line)
def draw_debug(self, drawing: svgwrite.Drawing):
"""
Draw some debug lines.
"""
"""Draw some debug lines."""
line = drawing.path(
("M", self.point_1, "L", self.point_2),
fill="none",
@ -237,9 +228,7 @@ class RoadPart:
# self.draw_entrance(drawing, True)
def draw(self, drawing: svgwrite.Drawing):
"""
Draw road part.
"""
"""Draw road part."""
if self.left_connection is not None:
path_commands = [
"M", self.point_2 + self.right_vector,
@ -251,9 +240,7 @@ class RoadPart:
drawing.add(drawing.path(path_commands, fill="#CCCCCC"))
def draw_entrance(self, drawing: svgwrite.Drawing, is_debug: bool = False):
"""
Draw intersection entrance part.
"""
"""Draw intersection entrance part."""
if (
self.left_connection is not None
and self.right_connection is not None
@ -277,9 +264,7 @@ class RoadPart:
drawing.add(drawing.path(path_commands, fill="#88FF88"))
def draw_lanes(self, drawing: svgwrite.Drawing, scale: float):
"""
Draw lane delimiters.
"""
"""Draw lane delimiters."""
for lane in self.lanes:
shift = self.right_vector - self.turned * lane.get_width(scale)
path = drawing.path(
@ -340,9 +325,7 @@ class Intersection:
part_2.update()
def draw(self, drawing: svgwrite.Drawing, is_debug: bool = False) -> None:
"""
Draw all road parts and intersection.
"""
"""Draw all road parts and intersection."""
inner_commands = ["M"]
for part in self.parts:
inner_commands += [part.left_connection, "L"]

View file

@ -413,12 +413,6 @@ class Scheme:
if main_icon and color:
main_icon.recolor(color)
# keys_left = [
# x
# for x in tags.keys()
# if x not in processed and not self.is_no_drawable(x)
# ]
default_shape = extractor.get_shape(DEFAULT_SHAPE_ID)
if not main_icon:
main_icon = Icon([ShapeSpecification(default_shape)])

View file

@ -1,5 +1,5 @@
"""
Röntgen tile server for sloppy maps.
Röntgen tile server for slippy maps.
"""
import logging
from http.server import HTTPServer, SimpleHTTPRequestHandler
@ -15,13 +15,14 @@ __author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
class Handler(SimpleHTTPRequestHandler):
class _Handler(SimpleHTTPRequestHandler):
"""
HTTP request handler that process sloppy map tile requests.
"""
cache: Path = Path("cache")
update_cache: bool = False
options = None
def __init__(
self, request: bytes, client_address: tuple[str, int], server
@ -33,22 +34,25 @@ class Handler(SimpleHTTPRequestHandler):
parts: list[str] = self.path.split("/")
if not (len(parts) == 5 and not parts[0] and parts[1] == "tiles"):
return
zoom: int = int(parts[2])
scale: int = int(parts[2])
x: int = int(parts[3])
y: int = int(parts[4])
tile_path: Path = workspace.get_tile_path()
png_path = tile_path / f"tile_{zoom}_{x}_{y}.png"
png_path: Path = tile_path / f"tile_{scale}_{x}_{y}.png"
if self.update_cache:
svg_path = tile_path / f"tile_{zoom}_{x}_{y}.svg"
svg_path: Path = png_path.with_suffix(".svg")
if not png_path.exists():
if not svg_path.exists():
tile = Tile(x, y, zoom)
tile.draw(tile_path, self.cache)
tile = Tile(x, y, scale)
tile.draw(tile_path, self.cache, self.options)
with svg_path.open() as input_file:
cairosvg.svg2png(
file_obj=input_file, write_to=str(png_path)
)
logging.info(f"SVG file is rasterized to {png_path}.")
if png_path.exists():
with png_path.open("rb") as input_file:
self.send_response(200)
@ -63,11 +67,12 @@ def ui(options) -> None:
server: Optional[HTTPServer] = None
try:
port: int = 8080
handler = Handler
handler = _Handler
handler.cache = Path(options.cache)
handler.options = options
server: HTTPServer = HTTPServer(("", port), handler)
server.serve_forever()
logging.info(f"Server started on port {port}.")
server.serve_forever()
finally:
if server:
server.socket.close()

View file

@ -75,16 +75,12 @@ def format_voltage(value: str) -> str:
def format_frequency(value: str) -> str:
"""
Format frequency value to more human-readable form.
"""
"""Format frequency value to more human-readable form."""
return f"{value} "
def get_text(tags: dict[str, Any], processed: set[str]) -> list[Label]:
"""
Get text representation of writable tags.
"""
"""Get text representation of writable tags."""
texts: list[Label] = []
values: list[str] = []

View file

@ -21,8 +21,7 @@ from roentgen.mapper import Map
from roentgen.osm_getter import NetworkError, get_osm
from roentgen.osm_reader import OSMData, OSMReader
from roentgen.scheme import Scheme
from roentgen.ui import BoundaryBox
from roentgen.util import MinMax
from roentgen.boundary_box import BoundaryBox
from roentgen.workspace import workspace
__author__ = "Sergey Vartanov"
@ -116,36 +115,33 @@ class Tile:
f"https://tile.openstreetmap.org/{self.scale}/{self.x}/{self.y}.png"
)
def draw(self, directory_name: Path, cache_path: Path) -> None:
def draw(self, directory_name: Path, cache_path: Path, options) -> None:
"""
Draw tile to SVG and PNG files.
:param directory_name: output directory to storing tiles
:param cache_path: directory to store SVG and PNG tiles
:param options: drawing configuration
"""
try:
osm_data: OSMData = self.load_osm_data(cache_path)
except NetworkError as e:
raise NetworkError(f"Map is not loaded. {e.message}")
self.draw_with_osm_data(osm_data, directory_name)
self.draw_with_osm_data(osm_data, directory_name, options)
def draw_with_osm_data(
self, osm_data: OSMData, directory_name: Path
self, osm_data: OSMData, directory_name: Path, options
) -> None:
"""Draw SVG and PNG tile using OpenStreetMap data."""
latitude_1, longitude_1 = self.get_coordinates()
latitude_2, longitude_2 = Tile(
top, left = self.get_coordinates()
bottom, right = Tile(
self.x + 1, self.y + 1, self.scale
).get_coordinates()
min_: np.array = np.array(
(min(latitude_1, latitude_2), min(longitude_1, longitude_2))
flinger: Flinger = Flinger(
BoundaryBox(left, bottom, right, top), self.scale
)
max_: np.array = np.array(
(max(latitude_1, latitude_2), max(longitude_1, longitude_2))
)
flinger: Flinger = Flinger(MinMax(min_, max_), self.scale)
size: np.array = flinger.size
output_file_name: Path = self.get_file_name(directory_name)
@ -158,11 +154,13 @@ class Tile:
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
constructor: Constructor = Constructor(
osm_data, flinger, scheme, icon_extractor
osm_data, flinger, scheme, icon_extractor, options
)
constructor.construct()
painter: Map = Map(flinger=flinger, svg=svg, scheme=scheme)
painter: Map = Map(
flinger=flinger, svg=svg, scheme=scheme, options=options
)
painter.draw(constructor)
with output_file_name.open("w") as output_file:
@ -217,13 +215,16 @@ class Tiles:
return cls(tiles, tile_1, tile_2, scale, extended_boundary_box)
def draw_separately(self, directory: Path, cache_path: Path) -> None:
def draw_separately(
self, directory: Path, cache_path: Path, options
) -> None:
"""
Draw set of tiles as SVG file separately and rasterize them into a set
of PNG files with cairosvg.
:param directory: directory for tiles
:param cache_path: directory for temporary OSM files
:param options: drawing configuration
"""
cache_file_path: Path = (
cache_path / f"{self.boundary_box.get_format()}.osm"
@ -234,7 +235,7 @@ class Tiles:
for tile in self.tiles:
file_path: Path = tile.get_file_name(directory)
if not file_path.exists():
tile.draw_with_osm_data(osm_data, directory)
tile.draw_with_osm_data(osm_data, directory, options)
else:
logging.debug(f"File {file_path} already exists.")
@ -252,18 +253,19 @@ class Tiles:
"""Check whether all tiles are drawn."""
return all(x.exists(directory_name) for x in self.tiles)
def draw(self, directory: Path, cache_path: Path) -> None:
def draw(self, directory: Path, cache_path: Path, options) -> None:
"""
Draw one PNG image with all tiles and split it into a set of separate
PNG file with Pillow.
:param directory: directory for tiles
:param cache_path: directory for temporary OSM files
:param options: drawing configuration
"""
if self.tiles_exist(directory):
return
self.draw_image(cache_path)
self.draw_image(cache_path, options)
input_path: Path = self.get_file_path(cache_path).with_suffix(".png")
with input_path.open("rb") as input_file:
@ -288,11 +290,12 @@ class Tiles:
"""Get path of the output SVG file."""
return cache_path / f"{self.boundary_box.get_format()}_{self.scale}.svg"
def draw_image(self, cache_path: Path) -> None:
def draw_image(self, cache_path: Path, options) -> None:
"""
Draw all tiles as one picture.
:param cache_path: directory for temporary SVG file and OSM files
:param options: drawing configuration
"""
output_path: Path = self.get_file_path(cache_path)
@ -303,27 +306,29 @@ class Tiles:
get_osm(self.boundary_box, cache_file_path)
osm_data: OSMData = OSMReader().parse_osm_file(cache_file_path)
latitude_2, longitude_1 = self.tile_1.get_coordinates()
latitude_1, longitude_2 = Tile(
top, left = self.tile_1.get_coordinates()
bottom, right = Tile(
self.tile_2.x + 1, self.tile_2.y + 1, self.scale
).get_coordinates()
min_: np.ndarray = np.array((latitude_1, longitude_1))
max_: np.ndarray = np.array((latitude_2, longitude_2))
flinger: Flinger = Flinger(MinMax(min_, max_), self.scale)
flinger: Flinger = Flinger(
BoundaryBox(left, bottom, right, top), self.scale
)
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
constructor: Constructor = Constructor(
osm_data, flinger, scheme, extractor
osm_data, flinger, scheme, extractor, options=options
)
constructor.construct()
svg: svgwrite.Drawing = svgwrite.Drawing(
str(output_path), size=flinger.size
)
map_: Map = Map(flinger=flinger, svg=svg, scheme=scheme)
map_: Map = Map(
flinger=flinger, svg=svg, scheme=scheme, options=options
)
map_.draw(constructor)
logging.info(f"Writing output SVG {output_path}...")
@ -351,13 +356,13 @@ def ui(options) -> None:
)
tile: Tile = Tile.from_coordinates(np.array(coordinates), options.scale)
try:
tile.draw(directory, Path(options.cache))
tile.draw(directory, Path(options.cache), options)
except NetworkError as e:
logging.fatal(e.message)
elif options.tile:
scale, x, y = map(int, options.tile.split("/"))
tile: Tile = Tile(x, y, scale)
tile.draw(directory, Path(options.cache))
tile.draw(directory, Path(options.cache), options)
elif options.boundary_box:
boundary_box: Optional[BoundaryBox] = BoundaryBox.from_text(
options.boundary_box
@ -365,7 +370,7 @@ def ui(options) -> None:
if boundary_box is None:
sys.exit(1)
tiles: Tiles = Tiles.from_boundary_box(boundary_box, options.scale)
tiles.draw(directory, Path(options.cache))
tiles.draw(directory, Path(options.cache), options)
else:
logging.fatal(
"Specify either --coordinates, --boundary-box, or --tile."

View file

@ -2,16 +2,12 @@
Command-line user interface.
"""
import argparse
import re
import sys
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
import logging
from dataclasses import dataclass
import numpy as np
from enum import Enum
from roentgen.osm_reader import STAGES_OF_DECAY
@ -21,8 +17,15 @@ BOXES_LENGTH: int = len(BOXES)
AUTHOR_MODE: str = "author"
TIME_MODE: str = "time"
LATITUDE_MAX_DIFFERENCE: float = 0.5
LONGITUDE_MAX_DIFFERENCE: float = 0.5
class BuildingMode(Enum):
"""
Building drawing mode.
"""
FLAT = "flat"
ISOMETRIC = "isometric"
ISOMETRIC_NO_PARTS = "isometric-no-parts"
def parse_options(args) -> argparse.Namespace:
@ -32,8 +35,14 @@ def parse_options(args) -> argparse.Namespace:
)
subparser = parser.add_subparsers(dest="command")
add_render_arguments(subparser.add_parser("render"))
add_tile_arguments(subparser.add_parser("tile"))
tile_parser = subparser.add_parser("tile")
add_tile_arguments(tile_parser)
add_map_arguments(tile_parser)
render_parser = subparser.add_parser("render")
add_render_arguments(render_parser)
add_map_arguments(render_parser)
add_server_arguments(subparser.add_parser("server"))
add_element_arguments(subparser.add_parser("element"))
add_mapcss_arguments(subparser.add_parser("mapcss"))
@ -46,13 +55,36 @@ def parse_options(args) -> argparse.Namespace:
return arguments
def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
"""Add arguments for tile command."""
def add_map_arguments(parser: argparse.ArgumentParser) -> None:
"""Add map-specific arguments."""
parser.add_argument(
"-c",
"--coordinates",
metavar="<latitude>,<longitude>",
help="coordinates of any location inside the tile",
"--buildings",
metavar="<mode>",
default="flat",
choices=(x.value for x in BuildingMode),
help="building drawing mode: "
+ ", ".join(x.value for x in BuildingMode),
)
parser.add_argument(
"--mode",
default="normal",
help="map drawing mode",
metavar="<string>",
)
parser.add_argument(
"--overlap",
dest="overlap",
default=12,
type=int,
help="how many pixels should be left around icons and text",
metavar="<integer>",
)
parser.add_argument(
"--labels",
help="label drawing mode: `no`, `main`, or `all`",
dest="label_mode",
default="main",
metavar="<string>",
)
parser.add_argument(
"-s",
@ -62,6 +94,21 @@ def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
help="OSM zoom level",
default=18,
)
parser.add_argument(
"--level",
default="overground",
help="display only this floor level",
)
def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
"""Add arguments for tile command."""
parser.add_argument(
"-c",
"--coordinates",
metavar="<latitude>,<longitude>",
help="coordinates of any location inside the tile",
)
parser.add_argument(
"-t",
"--tile",
@ -126,47 +173,17 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
help="geo boundary box; if first value is negative, enclose the value "
"with quotes and use space before `-`",
)
parser.add_argument(
"-s",
"--scale",
metavar="<float>",
help="OSM zoom level (may not be integer)",
default=18,
type=float,
)
parser.add_argument(
"--cache",
help="path for temporary OSM files",
default="cache",
metavar="<path>",
)
parser.add_argument(
"--labels",
help="label drawing mode: `no`, `main`, or `all`",
dest="label_mode",
default="main",
)
parser.add_argument(
"--overlap",
dest="overlap",
default=12,
type=int,
help="how many pixels should be left around icons and text",
)
parser.add_argument(
"--mode",
default="normal",
help="map drawing mode",
)
parser.add_argument(
"--seed",
default="",
help="seed for random",
)
parser.add_argument(
"--level",
default=None,
help="display only this floor level",
metavar="<string>",
)
@ -219,91 +236,3 @@ def progress_bar(
f"{int(length - fill_length - 1) * ' '}{text}"
)
sys.stdout.write("\033[F")
@dataclass
class BoundaryBox:
"""
Rectangle that limit space on the map.
"""
left: float
bottom: float
right: float
top: float
@classmethod
def from_text(cls, boundary_box: str):
"""
Parse boundary box string representation.
Note, that:
left < right
bottom < top
:param boundary_box: boundary box string representation in the form of
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2> or simply
<left>,<bottom>,<right>,<top>.
"""
boundary_box = boundary_box.replace(" ", "")
matcher = re.match(
"(?P<left>[0-9.-]*),(?P<bottom>[0-9.-]*),"
+ "(?P<right>[0-9.-]*),(?P<top>[0-9.-]*)",
boundary_box,
)
if not matcher:
logging.fatal("Invalid boundary box.")
return None
try:
left: float = float(matcher.group("left"))
bottom: float = float(matcher.group("bottom"))
right: float = float(matcher.group("right"))
top: float = float(matcher.group("top"))
except ValueError:
logging.fatal("Invalid boundary box.")
return None
if left >= right:
logging.fatal("Negative horizontal boundary.")
return None
if bottom >= top:
logging.error("Negative vertical boundary.")
return None
if (
right - left > LONGITUDE_MAX_DIFFERENCE
or top - bottom > LATITUDE_MAX_DIFFERENCE
):
logging.error("Boundary box is too big.")
return None
return cls(left, bottom, right, top)
def get_left_top(self) -> (np.array, np.array):
"""Get left top corner of the boundary box."""
return self.top, self.left
def get_right_bottom(self) -> (np.array, np.array):
"""Get right bottom corner of the boundary box."""
return self.bottom, self.right
def round(self) -> "BoundaryBox":
"""Round boundary box."""
self.left = round(self.left * 1000) / 1000 - 0.001
self.bottom = round(self.bottom * 1000) / 1000 - 0.001
self.right = round(self.right * 1000) / 1000 + 0.001
self.top = round(self.top * 1000) / 1000 + 0.001
return self
def get_format(self) -> str:
"""
Get text representation of the boundary box:
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2>. Coordinates are
rounded to three digits after comma.
"""
return (
f"{self.left:.3f},{self.bottom:.3f},{self.right:.3f},{self.top:.3f}"
)

View file

@ -24,9 +24,7 @@ def compute_angle(vector: np.array):
def turn_by_angle(vector: np.array, angle: float):
"""
Turn vector by an angle.
"""
"""Turn vector by an angle."""
return np.array(
(
vector[0] * np.cos(angle) - vector[1] * np.sin(angle),
@ -36,16 +34,12 @@ def turn_by_angle(vector: np.array, angle: float):
def norm(vector: np.array) -> np.array:
"""
Compute vector with the same direction and length 1.
"""
"""Compute vector with the same direction and length 1."""
return vector / np.linalg.norm(vector)
class Line:
"""
Infinity line: Ax + By + C = 0.
"""
"""Infinity line: Ax + By + C = 0."""
def __init__(self, start: np.array, end: np.array) -> None:
# if start.near(end):
@ -63,15 +57,11 @@ class Line:
self.c -= self.a * shift.x + self.b * shift.y
def is_parallel(self, other: "Line") -> bool:
"""
If lines are parallel or equal.
"""
"""If lines are parallel or equal."""
return np.allclose(other.a * self.b - self.a * other.b, 0)
def get_intersection_point(self, other: "Line") -> np.array:
"""
Get point of intersection current line with other.
"""
"""Get point of intersection current line with other."""
if other.a * self.b - self.a * other.b == 0:
return np.array((0, 0))

View file

@ -689,25 +689,79 @@ node_icons:
- tags: {man_made: mast, tower:construction: lattice_guyed}
shapes: [lattice_guyed]
- tags: {man_made: mast, tower:type: lighting}
shapes: [tube, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
shapes:
- tube
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags: {man_made: mast, tower:type: communication}
shapes: [tube, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: lighting, tower:construction: guyed_tube}
shapes: [tube_guyed, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: communication, tower:construction: guyed_tube}
shapes: [tube_guyed, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: lighting, tower:construction: freestanding}
shapes: [tube, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: communication, tower:construction: freestanding}
shapes: [tube, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: lighting, tower:construction: guyed_lattice}
shapes: [lattice_guyed, {shape: light_left, offset: [-4, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: communication, tower:construction: guyed_lattice}
shapes: [lattice_guyed, {shape: wave_left, offset: [-4, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: lighting, tower:construction: lattice}
shapes: [lattice, {shape: light_left, offset: [-4, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: mast, tower:type: communication, tower:construction: lattice}
shapes: [lattice, {shape: wave_left, offset: [-4, -3]}, {shape: wave_right, offset: [3, -3]}]
shapes:
- tube
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: lighting
tower:construction: guyed_tube
shapes:
- tube_guyed
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: communication
tower:construction: guyed_tube
shapes:
- tube_guyed
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: lighting
tower:construction: freestanding
shapes:
- tube
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: communication
tower:construction: freestanding
shapes:
- tube
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: lighting
tower:construction: guyed_lattice
shapes:
- lattice_guyed
- {shape: light_left, offset: [-4, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: communication
tower:construction: guyed_lattice
shapes:
- lattice_guyed
- {shape: wave_left, offset: [-4, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: lighting
tower:construction: lattice
shapes:
- lattice
- {shape: light_left, offset: [-4, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: mast
tower:type: communication
tower:construction: lattice
shapes:
- lattice
- {shape: wave_left, offset: [-4, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags: {man_made: tower, tower:construction: guyed_tube}
shapes: [tube_guyed]
@ -718,25 +772,79 @@ node_icons:
- tags: {man_made: tower, tower:construction: lattice_guyed}
shapes: [lattice_guyed]
- tags: {man_made: tower, tower:type: lighting}
shapes: [tube, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
shapes:
- tube
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags: {man_made: tower, tower:type: communication}
shapes: [tube, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: lighting, tower:construction: guyed_tube}
shapes: [tube_guyed, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: communication, tower:construction: guyed_tube}
shapes: [tube_guyed, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: lighting, tower:construction: freestanding}
shapes: [tube, {shape: light_left, offset: [-3, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: communication, tower:construction: freestanding}
shapes: [tube, {shape: wave_left, offset: [-3, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: lighting, tower:construction: guyed_lattice}
shapes: [lattice_guyed, {shape: light_left, offset: [-4, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: communication, tower:construction: guyed_lattice}
shapes: [lattice_guyed, {shape: wave_left, offset: [-4, -3]}, {shape: wave_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: lighting, tower:construction: lattice}
shapes: [lattice, {shape: light_left, offset: [-4, -3]}, {shape: light_right, offset: [3, -3]}]
- tags: {man_made: tower, tower:type: communication, tower:construction: lattice}
shapes: [lattice, {shape: wave_left, offset: [-4, -3]}, {shape: wave_right, offset: [3, -3]}]
shapes:
- tube
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: lighting
tower:construction: guyed_tube
shapes:
- tube_guyed
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: communication
tower:construction: guyed_tube
shapes:
- tube_guyed
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: lighting
tower:construction: freestanding
shapes:
- tube
- {shape: light_left, offset: [-3, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: communication
tower:construction: freestanding
shapes:
- tube
- {shape: wave_left, offset: [-3, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: lighting
tower:construction: guyed_lattice
shapes:
- lattice_guyed
- {shape: light_left, offset: [-4, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: communication
tower:construction: guyed_lattice
shapes:
- lattice_guyed
- {shape: wave_left, offset: [-4, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: lighting
tower:construction: lattice
shapes:
- lattice
- {shape: light_left, offset: [-4, -3]}
- {shape: light_right, offset: [3, -3]}
- tags:
man_made: tower
tower:type: communication
tower:construction: lattice
shapes:
- lattice
- {shape: wave_left, offset: [-4, -3]}
- {shape: wave_right, offset: [3, -3]}
- tags: {communication:mobile_phone: "yes"}
add_shapes: [phone]
@ -873,7 +981,9 @@ node_icons:
- group: "Entrances"
tags:
- tags: {amenity: parking_entrance}
shapes: [{shape: p, offset: [-1, 0]}, {shape: arrow_right, offset: [4, 5]}]
shapes:
- {shape: p, offset: [-1, 0]}
- {shape: arrow_right, offset: [4, 5]}
- tags: {amenity: parking_entrance, parking: underground}
shapes: [{shape: p, offset: [-1, 0]}, {shape: arrow_down, offset: [4, 5]}]
- tags: {amenity: parking_entrance, parking: multi-storey}

View file

@ -1,13 +1,14 @@
"""
Test boundary box.
"""
from roentgen.ui import BoundaryBox
from roentgen.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
def test_round_zero_coordinates() -> None:
"""Test rounding for zero coordinates."""
assert (
BoundaryBox(0, 0, 0, 0).round().get_format()
== "-0.001,-0.001,0.001,0.001"
@ -15,6 +16,7 @@ def test_round_zero_coordinates() -> None:
def test_round_coordinates() -> None:
"""Test rounding coordinates."""
box: BoundaryBox = BoundaryBox(
10.067596435546875,
46.094186149226466,