diff --git a/map_machine/feature/road.py b/map_machine/feature/road.py index 0703da8..1cbfeb5 100644 --- a/map_machine/feature/road.py +++ b/map_machine/feature/road.py @@ -9,7 +9,9 @@ import numpy as np from colour import Color +from shapely.geometry import LineString from svgwrite.path import Path +from svgwrite.text import TextPath from map_machine.geometry.vector import ( Line, @@ -28,6 +30,7 @@ from map_machine.drawing import PathCommands from map_machine.geometry.flinger import Flinger + from map_machine.pictogram.point import Occupied from map_machine.scheme import RoadMatcher, Scheme __author__ = "Sergey Vartanov" @@ -640,31 +643,110 @@ def draw_lanes(self, svg: Drawing, color: Color | None = None) -> None: path.update(style) svg.add(path) - def draw_caption(self, svg: Drawing) -> None: - """Draw road name along its path.""" + def draw_caption( + self, + svg: Drawing, + occupied: Occupied | None, + path_id: str, + ) -> bool: + """Draw road name along its path with collision detection. + + :param svg: SVG drawing + :param occupied: occupied pixel matrix for collision detection + :param path_id: unique identifier for the SVG path element + :return: True if the label was drawn, False if skipped + """ + if self.is_area: + return False + name: str | None = self.tags.get("name") if not name: - return + return False - if ( - path_commands := self.line.get_path(self.placement_offset + 3.0) - ) is None: - return + font_size: float = self.matcher.font_size + char_width: float = font_size * 0.6 + text_width: float = len(name) * char_width - path: Path = svg.path(d=path_commands, fill="none") - svg.add(path) + road_length: float = self.line.length() + if road_length < text_width * 1.5: + return False + + # Skip very curvy roads where text would be illegible. + straight_distance: float = float( + np.linalg.norm(self.line.points[-1] - self.line.points[0]) + ) + if straight_distance < road_length * 0.3: + return False + + # Ensure text reads left-to-right. + line: Polyline = self.line + if not line.is_left_to_right(): + line = line.reversed() + + # Compute the offset path used for text rendering. + text_offset: float = self.placement_offset - font_size * 0.35 + path_commands: str | None = line.get_path(text_offset) + if path_commands is None: + return False + + # Collision detection along the actual offset path where text renders. + if occupied is not None: + half_band: float = font_size * 0.7 + sample_interval: float = 1.0 + + # Build the offset geometry to sample from. + offset_line = LineString(line.points) + if not np.allclose(text_offset, 0.0): + offset_line = offset_line.parallel_offset(text_offset) + + total_length: float = offset_line.length + text_start: float = (total_length - text_width) / 2.0 + text_end: float = (total_length + text_width) / 2.0 + + # Check phase. + distance = text_start + while distance <= text_end: + center = offset_line.interpolate(distance) + cx, cy = int(center.x), int(center.y) + for dy in range(int(-half_band), int(half_band) + 1): + if occupied.check(np.array((cx, cy + dy))): + return False + distance += sample_interval + + # Register phase. + distance = text_start + while distance <= text_end: + center = offset_line.interpolate(distance) + cx, cy = int(center.x), int(center.y) + for d in range(int(-half_band), int(half_band) + 1): + occupied.register(np.array((cx, cy + d))) + occupied.register(np.array((cx + d, cy))) + distance += sample_interval + + # Place the invisible reference path in . + ref_path = svg.path( + d=path_commands, id=path_id, fill="none", stroke="none" + ) + svg.defs.add(ref_path) - text = svg.add(svg.text.Text("")) - text_path = svg.text.TextPath( - path=path, + text_element = svg.text( + "", + font_size=font_size, + font_family="Helvetica", + fill="#333333", + ) + text_path_element = TextPath( + ref_path, text=name, - startOffset=None, + startOffset="50%", method="align", spacing="exact", - font_family="Roboto", - font_size=10.0, ) - text.add(text_path) + text_path_element["text-anchor"] = "middle" + text_element.add(text_path_element) + svg.add(text_element) + + return True def get_curve_points( @@ -916,9 +998,7 @@ def draw_simple(self, svg: Drawing) -> None: ): road.draw(svg, is_border=False) - def draw_lanes( - self, svg: Drawing, flinger: Flinger, *, draw_captions: bool = False - ) -> None: + def draw_lanes(self, svg: Drawing, flinger: Flinger) -> None: """Draw whole road system with lanes and width.""" if not self.roads: return @@ -1027,6 +1107,29 @@ def draw_lanes( for road in roads: road.draw_lanes(svg, road.matcher.border_color) - if draw_captions: - for road in self.roads: - road.draw_caption(svg) + def draw_labels(self, svg: Drawing, occupied: Occupied | None) -> None: + """Draw road name labels with collision detection. + + Labels higher-priority roads first. Only one label per unique road + name to avoid repetition. + """ + sorted_roads: list[Road] = sorted( + self.roads, + key=lambda r: (-r.matcher.priority, -r.line.length()), + ) + + labeled_names: set[str] = set() + path_counter: int = 0 + + for road in sorted_roads: + name: str | None = road.tags.get("name") + if not name: + continue + + if name in labeled_names: + continue + + path_id: str = f"road-label-path-{path_counter}" + if road.draw_caption(svg, occupied, path_id): + labeled_names.add(name) + path_counter += 1 diff --git a/map_machine/geometry/vector.py b/map_machine/geometry/vector.py index b5f15aa..3891aa2 100644 --- a/map_machine/geometry/vector.py +++ b/map_machine/geometry/vector.py @@ -93,6 +93,20 @@ def get_path(self, parallel_offset: float = 0.0) -> str | None: + (" Z" if np.allclose(points[0], points[-1]) else "") ) + def length(self) -> float: + """Get the total length of the polyline in pixels.""" + return LineString(self.points).length + + def is_left_to_right(self) -> bool: + """Check whether the polyline runs generally left-to-right.""" + if len(self.points) < 2: + return True + return self.points[-1][0] >= self.points[0][0] + + def reversed(self) -> Polyline: + """Return a new Polyline with points in reversed order.""" + return Polyline(list(reversed(self.points))) + def shorten(self, index: int, length: float) -> None: """Shorten the part at the specified index.""" index_2: int = 1 if index == 0 else -2 diff --git a/map_machine/mapper.py b/map_machine/mapper.py index fb65356..839ad6e 100644 --- a/map_machine/mapper.py +++ b/map_machine/mapper.py @@ -131,6 +131,22 @@ def draw(self, constructor: Constructor) -> None: # Experimental debug drawing: # `self.draw_complex_roads(constructor.roads.roads)`. + occupied: Occupied | None = None + if self.configuration.overlap != 0: + occupied = Occupied( + int(self.flinger.size[0]), + int(self.flinger.size[1]), + self.configuration.overlap, + ) + + # Draw road labels after roads, before other features. + if ( + self.configuration.road_mode != RoadMode.NO + and self.configuration.label_mode != LabelMode.NO + ): + logger.info("Drawing road labels...") + constructor.roads.draw_labels(self.svg, occupied) + for figure in top_figures: path_commands = figure.get_path(self.flinger) @@ -161,16 +177,6 @@ def draw(self, constructor: Constructor) -> None: # All other points if self.scheme.nodes: - occupied: Occupied | None - if self.configuration.overlap == 0: - occupied = None - else: - occupied = Occupied( - int(self.flinger.size[0]), - int(self.flinger.size[1]), - self.configuration.overlap, - ) - nodes: list[Point] = sorted( constructor.points, key=lambda x: -x.priority ) diff --git a/map_machine/pictogram/point.py b/map_machine/pictogram/point.py index f854cb0..b7aee0f 100644 --- a/map_machine/pictogram/point.py +++ b/map_machine/pictogram/point.py @@ -70,6 +70,22 @@ def register(self, point: np.ndarray) -> None: self.matrix[point[0], point[1]] = True assert self.matrix[point[0], point[1]] + def dump_debug_image(self, path: str) -> None: + """Write occupied matrix as a PNG image for debugging. + + White pixels are free, black pixels are occupied. + """ + from PIL import Image # noqa: PLC0415 + + # Matrix is (width, height) with [x, y] indexing; transpose to get + # (height, width) row-major image. + pixel_data: np.ndarray = np.where(self.matrix.T, 0, 255).astype( + np.uint8 + ) + image: Image.Image = Image.fromarray(pixel_data, mode="L") + image.save(path) + logger.info("Occupied debug image saved to `%s`.", path) + def draw_icon_safe( icon_specification: IconSpecification, diff --git a/map_machine/scheme.py b/map_machine/scheme.py index 80b1b16..f8ddae5 100644 --- a/map_machine/scheme.py +++ b/map_machine/scheme.py @@ -322,6 +322,7 @@ class RoadMatcher(Matcher): color: Color | None = None default_width: float | None = None priority: float = 0.0 + font_size: float = 10.0 @classmethod def from_structure( @@ -340,6 +341,7 @@ def from_structure( road_matcher.color = Color(scheme.get_color(structure["color"])) road_matcher.default_width = structure["default_width"] road_matcher.priority = structure.get("priority", 0.0) + road_matcher.font_size = structure.get("font_size", 10.0) return road_matcher def get_priority(self, tags: Tags) -> float: diff --git a/map_machine/scheme/roads.yml b/map_machine/scheme/roads.yml index 71adcfb..89fa64b 100644 --- a/map_machine/scheme/roads.yml +++ b/map_machine/scheme/roads.yml @@ -4,81 +4,98 @@ roads: border_color: $motorway_border_color color: $motorway_color priority: 41.8 + font_size: 12.0 - tags: {highway: trunk} default_width: 7.0 border_color: $motorway_border_color color: $motorway_color priority: 41.0 + font_size: 12.0 - tags: {highway: trunk_link} default_width: 7.0 border_color: $motorway_border_color color: $motorway_color priority: 41.0 + font_size: 12.0 - tags: {highway: primary} default_width: 7.0 border_color: $primary_border_color color: $primary_color priority: 41.7 + font_size: 11.0 - tags: {highway: primary_link} default_width: 7.0 border_color: $primary_border_color color: $primary_color priority: 41.7 + font_size: 11.0 - tags: {highway: motorway_link} default_width: 7.0 border_color: $motorway_border_color color: $motorway_color priority: 41.8 + font_size: 12.0 - tags: {highway: secondary} default_width: 7.0 border_color: $secondary_border_color priority: 41.6 color: $secondary_color + font_size: 10.0 - tags: {highway: secondary_link} default_width: 7.0 border_color: $secondary_border_color priority: 41.6 color: $secondary_color + font_size: 10.0 - tags: {highway: tertiary} default_width: 7.0 border_color: $tertiary_border_color priority: 41.5 color: $tertiary_color + font_size: 10.0 - tags: {highway: tertiary_link} default_width: 7.0 border_color: $tertiary_border_color priority: 41.5 color: $tertiary_color + font_size: 10.0 - tags: {highway: unclassified} default_width: 5.0 border_color: $road_border_color priority: 41.0 + font_size: 9.0 - tags: {highway: residential} default_width: 5.0 border_color: $road_border_color priority: 41.0 + font_size: 9.0 - tags: {highway: living_street} default_width: 4.0 border_color: $road_border_color priority: 41.0 + font_size: 9.0 - tags: {highway: service} exception: {service: parking_aisle} default_width: 3.0 border_color: $road_border_color priority: 41.0 + font_size: 8.0 - tags: {highway: service, service: parking_aisle} default_width: 2.0 border_color: $road_border_color priority: 41.0 + font_size: 8.0 - tags: {leisure: track} exception: {area: "yes"} color: $pitch_color border_color: $pitch_border_color default_width: 5.0 priority: 21.0 + font_size: 9.0 - tags: {highway: raceway} color: $pitch_color border_color: $pitch_border_color default_width: 7.0 priority: 21.0 + font_size: 9.0 diff --git a/tests/test_command_line.py b/tests/test_command_line.py index f9599ce..4902e4d 100644 --- a/tests/test_command_line.py +++ b/tests/test_command_line.py @@ -20,6 +20,7 @@ b"INFO Constructing ways...\n" b"INFO Constructing nodes...\n" b"INFO Drawing ways...\n" + b"INFO Drawing road labels...\n" b"INFO Drawing main icons...\n" b"INFO Drawing extra icons...\n" b"INFO Drawing texts...\n" diff --git a/tests/test_road.py b/tests/test_road.py index c02db45..6cfaf77 100644 --- a/tests/test_road.py +++ b/tests/test_road.py @@ -7,10 +7,12 @@ from map_machine.feature.road import ( Road, + Roads, ) from map_machine.geometry.bounding_box import BoundingBox from map_machine.geometry.flinger import MercatorFlinger from map_machine.osm.osm_reader import OSMData, OSMNode +from map_machine.pictogram.point import Occupied from map_machine.scheme import Color, RoadMatcher from tests import SCHEME @@ -218,3 +220,74 @@ def test_road_draw_lanes() -> None: road.draw_lanes(svg, road.matcher.border_color) # For 2 lanes, should draw 1 separator. assert len(svg.elements) >= 0 + + +def test_road_draw_caption_no_name() -> None: + """Test that draw_caption returns False for unnamed roads.""" + road: Road = create_test_road({"highway": "primary"}) + svg: svgwrite.Drawing = svgwrite.Drawing(size=("100px", "100px")) + result: bool = road.draw_caption(svg, None, "test-path-0") + assert result is False + + +def test_road_draw_caption_too_short() -> None: + """Test that draw_caption returns False for roads too short.""" + nodes: list[OSMNode] = [ + OSMNode({}, 1, np.array((0.0, 0.0))), + OSMNode({}, 2, np.array((0.000001, 0.000001))), + ] + road: Road = create_test_road( + {"highway": "primary", "name": "Very Long Road Name That Cannot Fit"}, + nodes, + ) + svg: svgwrite.Drawing = svgwrite.Drawing(size=("100px", "100px")) + result: bool = road.draw_caption(svg, None, "test-path-0") + assert result is False + + +def test_road_draw_caption_with_name() -> None: + """Test that draw_caption succeeds for a named road.""" + road: Road = create_test_road({"highway": "primary", "name": "A St"}) + svg: svgwrite.Drawing = svgwrite.Drawing(size=("800px", "800px")) + result: bool = road.draw_caption(svg, None, "test-path-0") + assert isinstance(result, bool) + + +def test_road_draw_caption_collision() -> None: + """Test that draw_caption respects collision detection.""" + road: Road = create_test_road({"highway": "primary", "name": "Main St"}) + svg: svgwrite.Drawing = svgwrite.Drawing(size=("800px", "800px")) + occupied: Occupied = Occupied(800, 800, 12) + + # First label should succeed. + result1: bool = road.draw_caption(svg, occupied, "test-path-0") + if result1: + # Second label at same position should fail due to collision. + result2: bool = road.draw_caption(svg, occupied, "test-path-1") + assert result2 is False + + +def test_roads_draw_labels_no_repetition() -> None: + """Test that draw_labels only labels each road name once.""" + roads: Roads = Roads() + # Create two road segments with the same name but different positions. + for i in range(2): + offset = i * 0.005 + nodes: list[OSMNode] = [ + OSMNode({}, i * 10 + 1, np.array((-0.008 + offset, -0.008))), + OSMNode({}, i * 10 + 2, np.array((0.008 + offset, 0.008))), + ] + road: Road = create_test_road( + {"highway": "primary", "name": "Main Street"}, nodes + ) + roads.append(road) + + svg: svgwrite.Drawing = svgwrite.Drawing(size=("800px", "800px")) + occupied: Occupied = Occupied(800, 800, 12) + roads.draw_labels(svg, occupied) + + # Count textPath elements - should be at most 1 set (3 text elements + # per label: 2 halo + 1 fill). + svg_xml: str = svg.tostring() + count: int = svg_xml.count("Main Street") + assert count <= 3 diff --git a/tests/test_vector.py b/tests/test_vector.py index 2ac13a4..b406e4d 100644 --- a/tests/test_vector.py +++ b/tests/test_vector.py @@ -2,7 +2,7 @@ import numpy as np -from map_machine.geometry.vector import compute_angle, turn_by_angle +from map_machine.geometry.vector import Polyline, compute_angle, turn_by_angle __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" @@ -27,3 +27,43 @@ def test_turn_by_compute_angle() -> None: assert np.allclose( turn_by_angle(np.array((1, 0)), np.pi / 2), np.array((0, 1)) ) + + +def test_polyline_length() -> None: + """Test polyline length computation.""" + points = [np.array((0.0, 0.0)), np.array((3.0, 4.0))] + polyline = Polyline(points) + assert np.isclose(polyline.length(), 5.0) + + +def test_polyline_length_multi_segment() -> None: + """Test polyline length with multiple segments.""" + points = [ + np.array((0.0, 0.0)), + np.array((3.0, 0.0)), + np.array((3.0, 4.0)), + ] + polyline = Polyline(points) + assert np.isclose(polyline.length(), 7.0) + + +def test_polyline_is_left_to_right() -> None: + """Test polyline direction detection.""" + ltr = Polyline([np.array((0.0, 0.0)), np.array((10.0, 0.0))]) + assert ltr.is_left_to_right() + + rtl = Polyline([np.array((10.0, 0.0)), np.array((0.0, 0.0))]) + assert not rtl.is_left_to_right() + + vertical = Polyline([np.array((5.0, 0.0)), np.array((5.0, 10.0))]) + assert vertical.is_left_to_right() + + +def test_polyline_reversed() -> None: + """Test polyline reversal.""" + p = Polyline([np.array((0.0, 0.0)), np.array((10.0, 5.0))]) + r = p.reversed() + assert np.allclose(r.points[0], np.array((10.0, 5.0))) + assert np.allclose(r.points[1], np.array((0.0, 0.0))) + # Original should be unchanged. + assert np.allclose(p.points[0], np.array((0.0, 0.0)))