diff --git a/src/c3nav/mapdata/render/data.py b/src/c3nav/mapdata/render/data.py index 5771fd03..1ca74130 100644 --- a/src/c3nav/mapdata/render/data.py +++ b/src/c3nav/mapdata/render/data.py @@ -1,10 +1,14 @@ +import operator import pickle +from collections import namedtuple +from functools import reduce from itertools import chain import numpy as np from django.core.cache import cache from django.db import transaction -from shapely.geometry import LineString, MultiLineString +from django.utils.functional import cached_property +from shapely.geometry import GeometryCollection, LineString, MultiLineString from shapely.ops import unary_union from c3nav.mapdata.cache import MapHistory @@ -14,17 +18,32 @@ from c3nav.mapdata.utils.mesh import triangulate_rings from c3nav.mapdata.utils.mpl import shapely_to_mpl -class HybridGeometry: - def __init__(self, geom, faces): - self._geom = geom - self._faces = faces +def hybrid_union(geoms): + if not geoms: + return HybridGeometry(GeometryCollection(), set()) + if len(geoms) == 1: + return geoms[0] + return HybridGeometry(unary_union(tuple(geom.geom for geom in geoms)), + reduce(operator.or_, (geom.faces for geom in geoms), set())) + +class HybridGeometry(namedtuple('HybridGeometry', ('geom', 'faces'))): @classmethod def create(cls, geom, face_centers): if isinstance(geom, (LineString, MultiLineString)): return HybridGeometry(geom, set()) return HybridGeometry(geom, set(np.argwhere(shapely_to_mpl(geom).contains_points(face_centers)).flatten())) + def union(self, geom): + return HybridGeometry(self.geom.union(geom.geom), self.faces | geom.faces) + + def difference(self, geom): + return HybridGeometry(self.geom.difference(geom.geom), self.faces - geom.faces) + + @cached_property + def is_empty(self): + return not self.faces + class AltitudeAreaGeometries: def __init__(self, altitudearea=None, colors=None): @@ -37,7 +56,7 @@ class AltitudeAreaGeometries: self.colors = colors def get_geometries(self): - return chain((self.geometry, ), chain(*(areas.values() for areas in self.colors.values()))) + return chain((self.geometry,), chain(*(areas.values() for areas in self.colors.values()))) def create_hybrid_geometries(self, face_centers): self.geometry = HybridGeometry.create(self.geometry, face_centers) @@ -271,7 +290,7 @@ class LevelGeometries: return geoms def get_geometries(self): - return chain(chain(*(area.get_geometries() for area in self.altitudeareas)), (self.walls, self.doors, ), + return chain(chain(*(area.get_geometries() for area in self.altitudeareas)), (self.walls, self.doors,), self.restricted_spaces_indoors.values(), self.restricted_spaces_outdoors.values()) def create_hybrid_geometries(self, face_centers): @@ -287,7 +306,7 @@ class LevelGeometries: def build_mesh(self): rings = tuple(chain(*(get_rings(geom) for geom in self.get_geometries()))) self.vertices, self.faces = triangulate_rings(rings) - self.create_hybrid_geometries(face_centers=self.vertices[self.faces].sum(axis=1)/3) + self.create_hybrid_geometries(face_centers=self.vertices[self.faces].sum(axis=1) / 3) def get_level_render_data(level): diff --git a/src/c3nav/mapdata/render/engines/base.py b/src/c3nav/mapdata/render/engines/base.py index 1ed6a26c..76cdd542 100644 --- a/src/c3nav/mapdata/render/engines/base.py +++ b/src/c3nav/mapdata/render/engines/base.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from functools import lru_cache from typing import Optional +from shapely.geometry import JOIN_STYLE, box from shapely.ops import unary_union @@ -32,6 +33,7 @@ class RenderEngine(ABC): self.minx = xoff self.miny = yoff self.scale = scale + self.orig_buffer = buffer self.buffer = int(math.ceil(buffer*self.scale)) self.background = background @@ -42,6 +44,7 @@ class RenderEngine(ABC): self.buffer = int(math.ceil(buffer*self.scale)) self.buffered_width = self.width + 2 * self.buffer self.buffered_height = self.height + 2 * self.buffer + self.buffered_bbox = box(self.minx, self.miny, self.maxx, self.maxy).buffer(buffer, join_style=JOIN_STYLE.mitre) self.background_rgb = tuple(int(background[i:i + 2], 16)/255 for i in range(1, 6, 2)) @@ -83,14 +86,6 @@ class RenderEngine(ABC): # if altitude is not set but height is, the altitude will depend on the geometries below # if fill_color is set, filter out geometries that cannot be filled - if fill is not None: - try: - geometry.geoms - except AttributeError: - if not hasattr(geometry, 'exterior'): - return - else: - geometry = type(geometry)(tuple(geom for geom in geometry.geoms if hasattr(geom, 'exterior'))) if geometry.is_empty: return @@ -101,3 +96,6 @@ class RenderEngine(ABC): def _add_geometry(self, geometry, fill: Optional[FillAttribs] = None, stroke: Optional[StrokeAttribs] = None, altitude=None, height=None, shape_cache_key=None): pass + + def set_mesh_lookup_data(self, vertices, faces): + pass diff --git a/src/c3nav/mapdata/render/engines/opengl.py b/src/c3nav/mapdata/render/engines/opengl.py index c3ab5616..2dbfb22a 100644 --- a/src/c3nav/mapdata/render/engines/opengl.py +++ b/src/c3nav/mapdata/render/engines/opengl.py @@ -1,6 +1,6 @@ import io import threading -from collections import deque, namedtuple +from collections import namedtuple from itertools import chain from queue import Queue from typing import Optional, Tuple, Union @@ -11,6 +11,7 @@ from PIL import Image from shapely.geometry import CAP_STYLE, JOIN_STYLE, MultiPolygon, Polygon from shapely.ops import unary_union +from c3nav.mapdata.render.data import HybridGeometry from c3nav.mapdata.render.engines.base import FillAttribs, RenderEngine, StrokeAttribs from c3nav.mapdata.utils.mesh import triangulate_polygon @@ -143,13 +144,24 @@ class OpenGLEngine(RenderEngine): self.np_scale = np.array((scale_x, -scale_y)) self.np_offset = np.array((-self.minx * scale_x - 1, self.maxy * scale_y - 1)) - def _create_geometry(self, geometry: Union[Polygon, MultiPolygon], append=None): - triangles = deque() + # mesh data + self.vertices_lookup = None + self.faces_lookup = None - vertices, faces = triangulate_polygon(geometry) - triangles = vertices[faces.flatten()] + def set_mesh_lookup_data(self, vertices, faces): + self.vertices_lookup = vertices + self.faces_lookup = faces + + def _create_geometry(self, geometry: Union[Polygon, MultiPolygon, HybridGeometry], append=None): + if isinstance(geometry, HybridGeometry): + vertices = self.vertices_lookup[ + self.faces_lookup[np.array(tuple(geometry.faces))].flatten() + ].astype(np.float32) + else: + vertices, faces = triangulate_polygon(geometry) + triangles = vertices[faces.flatten()] + vertices = np.vstack(triangles).astype(np.float32) - vertices = np.vstack(triangles).astype(np.float32) vertices = vertices * self.np_scale + self.np_offset if append is not None: append = np.array(append, dtype=np.float32).flatten() @@ -162,18 +174,22 @@ class OpenGLEngine(RenderEngine): def _add_geometry(self, geometry, fill: Optional[FillAttribs] = None, stroke: Optional[StrokeAttribs] = None, altitude=None, height=None, shape_cache_key=None): if fill is not None: - if stroke is not None and fill.color == stroke.color: + if stroke is not None and fill.color == stroke.color and 0: geometry = geometry.buffer(max(stroke.width, (stroke.min_px or 0) / self.scale), cap_style=CAP_STYLE.flat, join_style=JOIN_STYLE.mitre) stroke = None self.vertices.append(self._create_geometry(geometry, self.color_to_rgb(fill.color))) if stroke is not None: + geometry = self.buffered_bbox.intersection(geometry.geom) lines = tuple(chain(*( ((geom.exterior, *geom.interiors) if isinstance(geom, Polygon) else (geom, )) for geom in getattr(geometry, 'geoms', (geometry, )) ))) + if not lines: + return + width = max(stroke.width, (stroke.min_px or 0) / self.scale) / 2 # if width would be <1px in the upsampled rendering, emulate it thorugh opacity on a 1px width @@ -193,7 +209,7 @@ class OpenGLEngine(RenderEngine): def get_png(self) -> bytes: return self.worker.render(self.width, self.height, self.samples, self.background_rgb, - np.hstack(self.vertices).astype(np.float32).tobytes()) + np.hstack(self.vertices).astype(np.float32).tobytes() if self.vertices else b'') OpenGLEngine.worker.start() diff --git a/src/c3nav/mapdata/render/engines/svg.py b/src/c3nav/mapdata/render/engines/svg.py index 28f01268..29f2d542 100644 --- a/src/c3nav/mapdata/render/engines/svg.py +++ b/src/c3nav/mapdata/render/engines/svg.py @@ -198,6 +198,7 @@ class SVGEngine(RenderEngine): def _add_geometry(self, geometry, fill: Optional[FillAttribs] = None, stroke: Optional[StrokeAttribs] = None, altitude=None, height=None, shape_cache_key=None): + geometry = self.buffered_bbox.intersection(geometry.geom) if fill: attribs = ' fill="'+(fill.color)+'"' diff --git a/src/c3nav/mapdata/render/renderer.py b/src/c3nav/mapdata/render/renderer.py index d51ad9a2..09375cc0 100644 --- a/src/c3nav/mapdata/render/renderer.py +++ b/src/c3nav/mapdata/render/renderer.py @@ -2,11 +2,10 @@ from django.core.cache import cache from django.utils.functional import cached_property from shapely import prepared from shapely.geometry import box -from shapely.ops import unary_union from c3nav.mapdata.cache import MapHistory from c3nav.mapdata.models import MapUpdate -from c3nav.mapdata.render.data import get_level_render_data +from c3nav.mapdata.render.data import get_level_render_data, hybrid_union from c3nav.mapdata.render.engines.base import FillAttribs, StrokeAttribs @@ -70,17 +69,18 @@ class MapRenderer: # add no access restriction to “unlocked“ access restrictions so lookup gets easier unlocked_access_restrictions = self.unlocked_access_restrictions | set([None]) - bbox = self.bbox - bbox_prep = prepared.prep(bbox) + bbox = prepared.prep(self.bbox) for geoms, default_height in self.level_render_data.levels: - if not bbox_prep.intersects(geoms.affected_area): + if not bbox.intersects(geoms.affected_area): continue + engine.set_mesh_lookup_data(geoms.vertices, geoms.faces) + # hide indoor and outdoor rooms if their access restriction was not unlocked - add_walls = unary_union(tuple(area for access_restriction, area in geoms.restricted_spaces_indoors.items() - if access_restriction not in unlocked_access_restrictions)) - crop_areas = unary_union( + add_walls = hybrid_union(tuple(area for access_restriction, area in geoms.restricted_spaces_indoors.items() + if access_restriction not in unlocked_access_restrictions)) + crop_areas = hybrid_union( tuple(area for access_restriction, area in geoms.restricted_spaces_outdoors.items() if access_restriction not in unlocked_access_restrictions) ).union(add_walls) @@ -88,7 +88,7 @@ class MapRenderer: # render altitude areas in default ground color and add ground colors to each one afterwards # shadows are directly calculated and added by the engine for altitudearea in geoms.altitudeareas: - engine.add_geometry(bbox.intersection(altitudearea.geometry.difference(crop_areas)), + engine.add_geometry(altitudearea.geometry.difference(crop_areas), altitude=altitudearea.altitude, fill=FillAttribs('#eeeeee')) for color, areas in altitudearea.colors.items(): @@ -96,18 +96,18 @@ class MapRenderer: areas = tuple(area for access_restriction, area in areas.items() if access_restriction in unlocked_access_restrictions) if areas: - engine.add_geometry(bbox.intersection(unary_union(areas)), fill=FillAttribs(color)) + engine.add_geometry(hybrid_union(areas), fill=FillAttribs(color)) # add walls, stroke_px makes sure that all walls are at least 1px thick on all zoom levels, walls = None if not add_walls.is_empty or not geoms.walls.is_empty: - walls = bbox.intersection(geoms.walls.union(add_walls)) + walls = geoms.walls.union(add_walls) if walls is not None: engine.add_geometry(walls, height=default_height, fill=FillAttribs('#aaaaaa')) if not geoms.doors.is_empty: - engine.add_geometry(bbox.intersection(geoms.doors.difference(add_walls)), fill=FillAttribs('#ffffff'), + engine.add_geometry(geoms.doors.difference(add_walls), fill=FillAttribs('#ffffff'), stroke=StrokeAttribs('#ffffff', 0.05, min_px=0.2)) if walls is not None: